diff --git a/.github/workflows/ai-moderator.lock.yml b/.github/workflows/ai-moderator.lock.yml index 40ad9d1ed4..228c004a01 100644 --- a/.github/workflows/ai-moderator.lock.yml +++ b/.github/workflows/ai-moderator.lock.yml @@ -6664,12 +6664,19 @@ jobs: runs-on: ubuntu-slim permissions: contents: read - discussions: write issues: write - pull-requests: write timeout-minutes: 15 outputs: - add_labels_labels_added: ${{ steps.add_labels.outputs.labels_added }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6682,998 +6689,175 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/safe_output_helpers.cjs << 'EOF_80a143d8' - // @ts-check - /// - - /** - * Shared helper functions for safe-output scripts - * Provides common validation and target resolution logic - */ - - /** - * Parse a comma-separated list of allowed items from environment variable - * @param {string|undefined} envValue - Environment variable value - * @returns {string[]|undefined} Array of allowed items, or undefined if no restrictions - */ - function parseAllowedItems(envValue) { - const trimmed = envValue?.trim(); - if (!trimmed) { - return undefined; - } - return trimmed - .split(",") - .map(item => item.trim()) - .filter(item => item); - } - - /** - * Parse and validate max count from environment variable - * @param {string|undefined} envValue - Environment variable value - * @param {number} defaultValue - Default value if not specified - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function parseMaxCount(envValue, defaultValue = 3) { - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - /** - * Resolve the target number (issue/PR) based on configuration and context - * @param {Object} params - Resolution parameters - * @param {string} params.targetConfig - Target configuration ("triggering", "*", or explicit number) - * @param {any} params.item - Safe output item with optional item_number or pull_request_number - * @param {any} params.context - GitHub Actions context - * @param {string} params.itemType - Type of item being processed (for error messages) - * @param {boolean} params.supportsPR - Whether this safe output supports PR context - * @returns {{success: true, number: number, contextType: string} | {success: false, error: string, shouldFail: boolean}} Resolution result - */ - function resolveTarget(params) { - const { targetConfig, item, context, itemType, supportsPR = false } = params; - - // Check context type - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - - // Default target is "triggering" - const target = targetConfig || "triggering"; - - // Validate context for triggering mode - if (target === "triggering") { - if (supportsPR) { - if (!isIssueContext && !isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in issue or pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } else { - if (!isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } - } - - // Resolve target number - let itemNumber; - let contextType; - - if (target === "*") { - // Use item_number, issue_number, or pull_request_number from item - const numberField = supportsPR ? item.item_number || item.issue_number || item.pull_request_number : item.pull_request_number; - - if (numberField) { - itemNumber = typeof numberField === "number" ? numberField : parseInt(String(numberField), 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "item_number/issue_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, - shouldFail: true, - }; - } - contextType = supportsPR && (item.item_number || item.issue_number) ? "issue" : "pull request"; - } else { - return { - success: false, - error: `Target is "*" but no ${supportsPR ? "item_number/issue_number" : "pull_request_number"} specified in ${itemType} item`, - shouldFail: true, - }; - } - } else if (target !== "triggering") { - // Explicit number - itemNumber = parseInt(target, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "issue" : "pull request"} number in target configuration: ${target}`, - shouldFail: true, - }; - } - contextType = supportsPR ? "issue" : "pull request"; - } else { - // Use triggering context - if (isIssueContext) { - if (context.payload.issue) { - itemNumber = context.payload.issue.number; - contextType = "issue"; - } else { - return { - success: false, - error: "Issue context detected but no issue found in payload", - shouldFail: true, - }; - } - } else if (isPRContext) { - if (context.payload.pull_request) { - itemNumber = context.payload.pull_request.number; - contextType = "pull request"; - } else { - return { - success: false, - error: "Pull request context detected but no pull request found in payload", - shouldFail: true, - }; - } - } - } - - if (!itemNumber) { - return { - success: false, - error: `Could not determine ${supportsPR ? "issue or pull request" : "pull request"} number`, - shouldFail: true, - }; - } - - return { - success: true, - number: itemNumber, - contextType: contextType || (supportsPR ? "issue" : "pull request"), - }; - } - - module.exports = { - parseAllowedItems, - parseMaxCount, - resolveTarget, - }; - - EOF_80a143d8 - cat > /tmp/gh-aw/scripts/safe_output_processor.cjs << 'EOF_8f3864e2' - // @ts-check - /// - - /** - * Shared processor for safe-output scripts - * Provides common pipeline: load agent output, handle staged mode, parse config, resolve target - */ - - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { parseAllowedItems, resolveTarget } = require('/tmp/gh-aw/scripts/safe_output_helpers.cjs'); - const { getSafeOutputConfig, validateMaxCount } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs'); - - /** - * @typedef {Object} ProcessorConfig - * @property {string} itemType - The type field value to match in agent output (e.g., "add_labels") - * @property {string} configKey - The key to use when reading from config.json (e.g., "add_labels") - * @property {string} displayName - Human-readable name for logging (e.g., "Add Labels") - * @property {string} itemTypeName - Name used in error messages (e.g., "label addition") - * @property {boolean} [supportsPR] - When true, allows both issue AND PR contexts; when false, only PR context (default: false) - * @property {boolean} [supportsIssue] - When true, passes supportsPR=true to resolveTarget to enable both contexts (default: false) - * @property {boolean} [findMultiple] - Whether to find multiple items instead of just one (default: false) - * @property {Object} envVars - Environment variable names - * @property {string} [envVars.allowed] - Env var for allowed items list - * @property {string} [envVars.maxCount] - Env var for max count - * @property {string} [envVars.target] - Env var for target configuration - */ - - /** - * @typedef {Object} ProcessorResult - * @property {boolean} success - Whether processing should continue - * @property {any} [item] - The found item (when findMultiple is false) - * @property {any[]} [items] - The found items (when findMultiple is true) - * @property {Object} [config] - Parsed configuration - * @property {string[]|undefined} [config.allowed] - Allowed items list - * @property {number} [config.maxCount] - Maximum count - * @property {string} [config.target] - Target configuration - * @property {Object} [targetResult] - Result from resolveTarget (when findMultiple is false) - * @property {number} [targetResult.number] - Target issue/PR number - * @property {string} [targetResult.contextType] - Type of context (issue or pull request) - * @property {string} [reason] - Reason why processing should not continue - */ - - /** - * Process the initial steps common to safe-output scripts: - * 1. Load agent output - * 2. Find matching item(s) - * 3. Handle staged mode - * 4. Parse configuration - * 5. Resolve target (for single-item processors) - * - * @param {ProcessorConfig} config - Processor configuration - * @param {Object} stagedPreviewOptions - Options for staged preview - * @param {string} stagedPreviewOptions.title - Title for staged preview - * @param {string} stagedPreviewOptions.description - Description for staged preview - * @param {(item: any, index: number) => string} stagedPreviewOptions.renderItem - Function to render item in preview - * @returns {Promise} Processing result - */ - async function processSafeOutput(config, stagedPreviewOptions) { - const { itemType, configKey, displayName, itemTypeName, supportsPR = false, supportsIssue = false, findMultiple = false, envVars } = config; - - // Step 1: Load agent output - const result = loadAgentOutput(); - if (!result.success) { - return { success: false, reason: "Agent output not available" }; - } - - // Step 2: Find matching item(s) - let items; - if (findMultiple) { - items = result.items.filter(item => item.type === itemType); - if (items.length === 0) { - core.info(`No ${itemType} items found in agent output`); - return { success: false, reason: `No ${itemType} items found` }; - } - core.info(`Found ${items.length} ${itemType} item(s)`); - } else { - const item = result.items.find(item => item.type === itemType); - if (!item) { - core.warning(`No ${itemType.replace(/_/g, "-")} item found in agent output`); - return { success: false, reason: `No ${itemType} item found` }; - } - items = [item]; - // Log item details based on common fields - const itemDetails = getItemDetails(item); - if (itemDetails) { - core.info(`Found ${itemType.replace(/_/g, "-")} item with ${itemDetails}`); - } - } - - // Step 3: Handle staged mode - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - await generateStagedPreview({ - title: stagedPreviewOptions.title, - description: stagedPreviewOptions.description, - items: items, - renderItem: stagedPreviewOptions.renderItem, - }); - return { success: false, reason: "Staged mode - preview generated" }; - } - - // Step 4: Parse configuration - const safeOutputConfig = getSafeOutputConfig(configKey); - - // Parse allowed items (from env or config) - const allowedEnvValue = envVars.allowed ? process.env[envVars.allowed] : undefined; - const allowed = parseAllowedItems(allowedEnvValue) || safeOutputConfig.allowed; - if (allowed) { - core.info(`Allowed ${itemTypeName}s: ${JSON.stringify(allowed)}`); - } else { - core.info(`No ${itemTypeName} restrictions - any ${itemTypeName}s are allowed`); - } - - // Parse max count (env takes priority, then config) - const maxCountEnvValue = envVars.maxCount ? process.env[envVars.maxCount] : undefined; - const maxCountResult = validateMaxCount(maxCountEnvValue, safeOutputConfig.max); - if (!maxCountResult.valid) { - core.setFailed(maxCountResult.error); - return { success: false, reason: "Invalid max count configuration" }; - } - const maxCount = maxCountResult.value; - core.info(`Max count: ${maxCount}`); - - // Get target configuration - const target = envVars.target ? process.env[envVars.target] || "triggering" : "triggering"; - core.info(`${displayName} target configuration: ${target}`); - - // For multiple items, return early without target resolution - if (findMultiple) { - return { - success: true, - items: items, - config: { - allowed, - maxCount, - target, - }, - }; - } - - // Step 5: Resolve target (for single-item processors) - const item = items[0]; - const targetResult = resolveTarget({ - targetConfig: target, - item: item, - context, - itemType: itemTypeName, - // supportsPR in resolveTarget: true=both issue and PR contexts, false=PR-only - // If supportsIssue is true, we pass supportsPR=true to enable both contexts - supportsPR: supportsPR || supportsIssue, - }); - - if (!targetResult.success) { - if (targetResult.shouldFail) { - core.setFailed(targetResult.error); - } else { - core.info(targetResult.error); - } - return { success: false, reason: targetResult.error }; - } - - return { - success: true, - item: item, - config: { - allowed, - maxCount, - target, - }, - targetResult: { - number: targetResult.number, - contextType: targetResult.contextType, - }, - }; - } - - /** - * Get a description of item details for logging - * @param {any} item - The safe output item - * @returns {string|null} Description string or null - */ - function getItemDetails(item) { - if (item.labels && Array.isArray(item.labels)) { - return `${item.labels.length} labels`; - } - if (item.reviewers && Array.isArray(item.reviewers)) { - return `${item.reviewers.length} reviewers`; - } - return null; - } - - /** - * Sanitize and deduplicate an array of string items - * @param {any[]} items - Raw items array - * @returns {string[]} Sanitized and deduplicated array - */ - function sanitizeItems(items) { - return items - .filter(item => item != null && item !== false && item !== 0) - .map(item => String(item).trim()) - .filter(item => item) - .filter((item, index, arr) => arr.indexOf(item) === index); - } - - /** - * Filter items by allowed list - * @param {string[]} items - Items to filter - * @param {string[]|undefined} allowed - Allowed items list (undefined means all allowed) - * @returns {string[]} Filtered items - */ - function filterByAllowed(items, allowed) { - if (!allowed || allowed.length === 0) { - return items; - } - return items.filter(item => allowed.includes(item)); - } - - /** - * Limit items to max count - * @param {string[]} items - Items to limit - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Limited items - */ - function limitToMaxCount(items, maxCount) { - if (items.length > maxCount) { - core.info(`Too many items (${items.length}), limiting to ${maxCount}`); - return items.slice(0, maxCount); - } - return items; - } - - /** - * Process items through the standard pipeline: filter by allowed, sanitize, dedupe, limit - * @param {any[]} rawItems - Raw items array from agent output - * @param {string[]|undefined} allowed - Allowed items list - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Processed items - */ - function processItems(rawItems, allowed, maxCount) { - // Filter by allowed list first - const filtered = filterByAllowed(rawItems, allowed); - - // Sanitize and deduplicate - const sanitized = sanitizeItems(filtered); - - // Limit to max count - return limitToMaxCount(sanitized, maxCount); - } - - module.exports = { - processSafeOutput, - sanitizeItems, - filterByAllowed, - limitToMaxCount, - processItems, - }; - - EOF_8f3864e2 - cat > /tmp/gh-aw/scripts/safe_output_validator.cjs << 'EOF_437e6b4f' - // @ts-check - /// - - const fs = require("fs"); - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - - /** - * Load and parse the safe outputs configuration from config.json - * @returns {object} The parsed configuration object - */ - function loadSafeOutputsConfig() { - const configPath = "/tmp/gh-aw/safeoutputs/config.json"; - try { - if (!fs.existsSync(configPath)) { - core.warning(`Config file not found at ${configPath}, using defaults`); - return {}; - } - const configContent = fs.readFileSync(configPath, "utf8"); - return JSON.parse(configContent); - } catch (error) { - core.warning(`Failed to load config: ${error instanceof Error ? error.message : String(error)}`); - return {}; - } - } - - /** - * Get configuration for a specific safe output type - * @param {string} outputType - The type of safe output (e.g., "add_labels", "update_issue") - * @returns {{max?: number, target?: string, allowed?: string[]}} The configuration for this output type - */ - function getSafeOutputConfig(outputType) { - const config = loadSafeOutputsConfig(); - return config[outputType] || {}; - } - - /** - * Validate and sanitize a title string - * @param {any} title - The title to validate - * @param {string} fieldName - The name of the field for error messages (default: "title") - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateTitle(title, fieldName = "title") { - if (title === undefined || title === null) { - return { valid: false, error: `${fieldName} is required` }; - } - - if (typeof title !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - const trimmed = title.trim(); - if (trimmed.length === 0) { - return { valid: false, error: `${fieldName} cannot be empty` }; - } - - return { valid: true, value: trimmed }; - } - - /** - * Validate and sanitize a body/content string - * @param {any} body - The body to validate - * @param {string} fieldName - The name of the field for error messages (default: "body") - * @param {boolean} required - Whether the body is required (default: false) - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateBody(body, fieldName = "body", required = false) { - if (body === undefined || body === null) { - if (required) { - return { valid: false, error: `${fieldName} is required` }; - } - return { valid: true, value: "" }; - } - - if (typeof body !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - return { valid: true, value: body }; - } - - /** - * Validate and sanitize an array of labels - * @param {any} labels - The labels to validate - * @param {string[]|undefined} allowedLabels - Optional list of allowed labels - * @param {number} maxCount - Maximum number of labels allowed - * @returns {{valid: boolean, value?: string[], error?: string}} Validation result - */ - function validateLabels(labels, allowedLabels = undefined, maxCount = 3) { - if (!labels || !Array.isArray(labels)) { - return { valid: false, error: "labels must be an array" }; - } - - // Check for removal attempts (labels starting with '-') - for (const label of labels) { - if (label && typeof label === "string" && label.startsWith("-")) { - return { valid: false, error: `Label removal is not permitted. Found line starting with '-': ${label}` }; - } - } - - // Filter labels based on allowed list if provided - let validLabels = labels; - if (allowedLabels && allowedLabels.length > 0) { - validLabels = labels.filter(label => allowedLabels.includes(label)); - } - - // Sanitize and deduplicate labels - const uniqueLabels = validLabels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - - // Apply max count limit - if (uniqueLabels.length > maxCount) { - core.info(`Too many labels (${uniqueLabels.length}), limiting to ${maxCount}`); - return { valid: true, value: uniqueLabels.slice(0, maxCount) }; - } - - if (uniqueLabels.length === 0) { - return { valid: false, error: "No valid labels found after sanitization" }; - } - - return { valid: true, value: uniqueLabels }; - } - - /** - * Validate max count from environment variable with config fallback - * @param {string|undefined} envValue - Environment variable value - * @param {number|undefined} configDefault - Default from config.json - * @param {number} [fallbackDefault] - Fallback default for testing (optional, defaults to 1) - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function validateMaxCount(envValue, configDefault, fallbackDefault = 1) { - // Priority: env var > config.json > fallback default - // In production, config.json should always have the default - // Fallback is provided for backward compatibility and testing - const defaultValue = configDefault !== undefined ? configDefault : fallbackDefault; - - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - module.exports = { - loadSafeOutputsConfig, - getSafeOutputConfig, - validateTitle, - validateBody, - validateLabels, - validateMaxCount, - }; - - EOF_437e6b4f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - - name: Add Labels - id: add_labels - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_labels')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_LABELS_ALLOWED: "spam,ai-generated,link-spam,ai-inspected" - GH_AW_LABELS_TARGET: "*" - GH_AW_WORKFLOW_NAME: "AI Moderator" - GH_AW_ENGINE_ID: "copilot" - GH_AW_ENGINE_MODEL: "gpt-5-mini" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { processSafeOutput } = require('/tmp/gh-aw/scripts/safe_output_processor.cjs'); - const { validateLabels } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { - const result = await processSafeOutput( - { - itemType: "add_labels", - configKey: "add_labels", - displayName: "Labels", - itemTypeName: "label addition", - supportsPR: true, - supportsIssue: true, - envVars: { - allowed: "GH_AW_LABELS_ALLOWED", - maxCount: "GH_AW_LABELS_MAX_COUNT", - target: "GH_AW_LABELS_TARGET", - }, - }, - { - title: "Add Labels", - description: "The following labels would be added if staged mode was disabled:", - renderItem: item => { - let content = ""; - if (item.item_number) { - content += `**Target Issue:** #${item.item_number}\n\n`; - } else { - content += `**Target:** Current issue/PR\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels to add:** ${item.labels.join(", ")}\n\n`; - } - return content; - }, - } - ); + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; + const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const { item: labelsItem, config, targetResult } = result; - if (!config || !targetResult || targetResult.number === undefined) { - core.setFailed("Internal error: config, targetResult, or targetResult.number is undefined"); - return; - } - const { allowed: allowedLabels, maxCount } = config; - const itemNumber = targetResult.number; - const { contextType } = targetResult; - const requestedLabels = labelsItem.labels || []; - core.info(`Requested labels: ${JSON.stringify(requestedLabels)}`); - const labelsResult = validateLabels(requestedLabels, allowedLabels, maxCount); - if (!labelsResult.valid) { - if (labelsResult.error && labelsResult.error.includes("No valid labels")) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` - ## Label Addition - No labels were added (no valid labels found in agent output). - ` - ) - .write(); - return; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; + } } - core.setFailed(labelsResult.error || "Invalid labels"); - return; - } - const uniqueLabels = labelsResult.value || []; - if (uniqueLabels.length === 0) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` - ## Label Addition - No labels were added (no valid labels found in agent output). - ` - ) - .write(); + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${itemNumber}: ${JSON.stringify(uniqueLabels)}`); - try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - labels: uniqueLabels, - }); - core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${itemNumber}`); - core.setOutput("labels_added", uniqueLabels.join("\n")); - const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); - await core.summary - .addRaw( - ` - ## Label Addition - Successfully added ${uniqueLabels.length} label(s) to ${contextType} #${itemNumber}: - ${labelsListMarkdown} - ` - ) - .write(); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to add labels: ${errorMessage}`); - core.setFailed(`Failed to add labels: ${errorMessage}`); + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - } - (async () => { await main(); })(); - - name: Hide Comment - id: hide_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'hide_comment')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "AI Moderator" - GH_AW_ENGINE_ID: "copilot" - GH_AW_ENGINE_MODEL: "gpt-5-mini" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - async function hideComment(github, nodeId, reason = "spam") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - let allowedReasons = null; - if (process.env.GH_AW_HIDE_COMMENT_ALLOWED_REASONS) { - try { - allowedReasons = JSON.parse(process.env.GH_AW_HIDE_COMMENT_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${allowedReasons.join(", ")}]`); - } catch (error) { - core.warning(`Failed to parse GH_AW_HIDE_COMMENT_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - } } - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const hideCommentItems = result.items.filter( item => item.type === "hide_comment"); - if (hideCommentItems.length === 0) { - core.info("No hide-comment items found in agent output"); - return; - } - core.info(`Found ${hideCommentItems.length} hide-comment item(s)`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Hide Comments Preview\n\n"; - summaryContent += "The following comments would be hidden if staged mode was disabled:\n\n"; - for (let i = 0; i < hideCommentItems.length; i++) { - const item = hideCommentItems[i]; - const reason = item.reason || "spam"; - summaryContent += `### Comment ${i + 1}\n`; - summaryContent += `**Node ID**: ${item.comment_id}\n`; - summaryContent += `**Action**: Would be hidden as ${reason}\n`; - summaryContent += "\n"; - } - core.summary.addRaw(summaryContent).write(); - return; + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } } - for (const item of hideCommentItems) { - try { - const commentId = item.comment_id; - if (!commentId || typeof commentId !== "string") { - throw new Error("comment_id is required and must be a string (GraphQL node ID)"); - } - const reason = item.reason || "spam"; - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping comment ${commentId}.`); - continue; - } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - core.info(`Hiding comment: ${commentId} (reason: ${normalizedReason})`); - const hideResult = await hideComment(github, commentId, normalizedReason); - if (hideResult.isMinimized) { - core.info(`Successfully hidden comment: ${commentId}`); - core.setOutput("comment_id", commentId); - core.setOutput("is_hidden", "true"); - } else { - throw new Error(`Failed to hide comment: ${commentId}`); + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to hide comment: ${errorMessage}`); - core.setFailed(`Failed to hide comment: ${errorMessage}`); - return; } + await core.summary.addRaw(summaryContent).write(); } + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); diff --git a/.github/workflows/archie.lock.yml b/.github/workflows/archie.lock.yml index 1ddb434408..ca06fe4c1a 100644 --- a/.github/workflows/archie.lock.yml +++ b/.github/workflows/archie.lock.yml @@ -7670,8 +7670,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7684,1019 +7692,175 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Archie" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 📊 *Diagram rendered by [{workflow_name}]({run_url})*\",\"runStarted\":\"📐 Archie here! [{workflow_name}]({run_url}) is sketching the architecture on this {event_type}...\",\"runSuccess\":\"🎨 Blueprint complete! [{workflow_name}]({run_url}) has visualized the connections. The architecture speaks for itself! ✅\",\"runFailure\":\"📐 Drafting interrupted! [{workflow_name}]({run_url}) {status}. The diagram remains incomplete...\"}" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } - } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; - } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); - } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); diff --git a/.github/workflows/artifacts-summary.lock.yml b/.github/workflows/artifacts-summary.lock.yml index 126d0bc313..fa0d505c9f 100644 --- a/.github/workflows/artifacts-summary.lock.yml +++ b/.github/workflows/artifacts-summary.lock.yml @@ -6658,8 +6658,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6682,1173 +6690,178 @@ jobs: repositories: ${{ github.event.repository.name }} github-api-url: ${{ github.api_url }} permission-contents: read - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Artifacts Summary" - GH_AW_ENGINE_ID: "copilot" - with: + GH_AW_DISCUSSION_CATEGORY: "artifacts" +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ steps.app-token.outputs.token }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; } } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); - name: Invalidate GitHub App token if: always() && steps.app-token.outputs.token != '' env: diff --git a/.github/workflows/audit-workflows.lock.yml b/.github/workflows/audit-workflows.lock.yml index ab2e6506da..b57c7a6817 100644 --- a/.github/workflows/audit-workflows.lock.yml +++ b/.github/workflows/audit-workflows.lock.yml @@ -6985,8 +6985,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6999,1313 +7007,178 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Agentic Workflow Audit Agent" - GH_AW_TRACKER_ID: "audit-workflows-daily" - GH_AW_ENGINE_ID: "claude" - with: + GH_AW_DISCUSSION_CATEGORY: "audits" +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; } } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); - - name: Upload Assets - id: upload_assets - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'upload_asset')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Agentic Workflow Audit Agent" - GH_AW_TRACKER_ID: "audit-workflows-daily" - GH_AW_ENGINE_ID: "claude" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const path = require("path"); - const crypto = require("crypto"); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function normalizeBranchName(branchName) { - if (!branchName || typeof branchName !== "string" || branchName.trim() === "") { - return branchName; - } - let normalized = branchName.replace(/[^a-zA-Z0-9\-_/.]+/g, "-"); - normalized = normalized.replace(/-+/g, "-"); - normalized = normalized.replace(/^-+|-+$/g, ""); - if (normalized.length > 128) { - normalized = normalized.substring(0, 128); - } - normalized = normalized.replace(/-+$/, ""); - normalized = normalized.toLowerCase(); - return normalized; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const branchName = process.env.GH_AW_ASSETS_BRANCH; - if (!branchName || typeof branchName !== "string") { - core.setFailed("GH_AW_ASSETS_BRANCH environment variable is required but not set"); - return; - } - const normalizedBranchName = normalizeBranchName(branchName); - core.info(`Using assets branch: ${normalizedBranchName}`); - const result = loadAgentOutput(); - if (!result.success) { - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - const uploadItems = result.items.filter( item => item.type === "upload_assets"); - const uploadAssetItems = result.items.filter( item => item.type === "upload_asset"); - const allUploadItems = [...uploadItems, ...uploadAssetItems]; - if (allUploadItems.length === 0) { - core.info("No upload-asset items found in agent output"); - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - core.info(`Found ${allUploadItems.length} upload-asset item(s)`); - let uploadCount = 0; - let hasChanges = false; + (async () => { try { - try { - await exec.exec(`git rev-parse --verify origin/${normalizedBranchName}`); - await exec.exec(`git checkout -B ${normalizedBranchName} origin/${normalizedBranchName}`); - core.info(`Checked out existing branch from origin: ${normalizedBranchName}`); - } catch (originError) { - if (!normalizedBranchName.startsWith("assets/")) { - core.setFailed( - `Branch '${normalizedBranchName}' does not start with the required 'assets/' prefix. ` + - `Orphaned branches can only be automatically created under the 'assets/' prefix. ` + - `Please create the branch manually first, or use a branch name starting with 'assets/'.` - ); - return; - } - core.info(`Creating new orphaned branch: ${normalizedBranchName}`); - await exec.exec(`git checkout --orphan ${normalizedBranchName}`); - await exec.exec(`git rm -rf .`); - await exec.exec(`git clean -fdx`); - } - for (const asset of uploadAssetItems) { - try { - const { fileName, sha, size, targetFileName } = asset; - if (!fileName || !sha || !targetFileName) { - core.error(`Invalid asset entry missing required fields: ${JSON.stringify(asset)}`); - continue; - } - const assetSourcePath = path.join("/tmp/gh-aw/safeoutputs/assets", fileName); - if (!fs.existsSync(assetSourcePath)) { - core.warning(`Asset file not found: ${assetSourcePath}`); - continue; - } - const fileContent = fs.readFileSync(assetSourcePath); - const computedSha = crypto.createHash("sha256").update(fileContent).digest("hex"); - if (computedSha !== sha) { - core.warning(`SHA mismatch for ${fileName}: expected ${sha}, got ${computedSha}`); - continue; - } - if (fs.existsSync(targetFileName)) { - core.info(`Asset ${targetFileName} already exists, skipping`); - continue; - } - fs.copyFileSync(assetSourcePath, targetFileName); - await exec.exec(`git add "${targetFileName}"`); - uploadCount++; - hasChanges = true; - core.info(`Added asset: ${targetFileName} (${size} bytes)`); - } catch (error) { - core.warning(`Failed to process asset ${asset.fileName}: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (hasChanges) { - const commitMessage = `[skip-ci] Add ${uploadCount} asset(s)`; - await exec.exec(`git`, [`commit`, `-m`, commitMessage]); - if (isStaged) { - core.summary.addRaw("## Staged Asset Publication"); - } else { - await exec.exec(`git push origin ${normalizedBranchName}`); - core.summary.addRaw("## Assets").addRaw(`Successfully uploaded **${uploadCount}** assets to branch \`${normalizedBranchName}\``).addRaw(""); - core.info(`Successfully uploaded ${uploadCount} assets to branch ${normalizedBranchName}`); - } - for (const asset of uploadAssetItems) { - if (asset.fileName && asset.sha && asset.size && asset.url) { - core.summary.addRaw(`- [\`${asset.fileName}\`](${asset.url}) → \`${asset.targetFileName}\` (${asset.size} bytes)`); - } - } - core.summary.write(); - } else { - core.info("No new assets to upload"); - } + await main(); } catch (error) { - core.setFailed(`Failed to upload assets: ${error instanceof Error ? error.message : String(error)}`); - return; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - core.setOutput("upload_count", uploadCount.toString()); - core.setOutput("branch_name", normalizedBranchName); - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/blog-auditor.lock.yml b/.github/workflows/blog-auditor.lock.yml index 5744eea195..d833c9bc61 100644 --- a/.github/workflows/blog-auditor.lock.yml +++ b/.github/workflows/blog-auditor.lock.yml @@ -6468,8 +6468,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6482,1172 +6490,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Blog Auditor" - GH_AW_TRACKER_ID: "blog-auditor-weekly" - GH_AW_ENGINE_ID: "claude" - with: + GH_AW_DISCUSSION_CATEGORY: "Audits" + GH_AW_DISCUSSION_TITLE_PREFIX: "[audit] " +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; } } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); diff --git a/.github/workflows/brave.lock.yml b/.github/workflows/brave.lock.yml index f4994833d8..ae9eba411c 100644 --- a/.github/workflows/brave.lock.yml +++ b/.github/workflows/brave.lock.yml @@ -7549,8 +7549,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7563,1019 +7571,175 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Brave Web Search Agent" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🦁 *Search results brought to you by [{workflow_name}]({run_url})*\",\"runStarted\":\"🔍 Brave Search activated! [{workflow_name}]({run_url}) is venturing into the web on this {event_type}...\",\"runSuccess\":\"🦁 Mission accomplished! [{workflow_name}]({run_url}) has returned with the findings. Knowledge acquired! 🏆\",\"runFailure\":\"🔍 Search interrupted! [{workflow_name}]({run_url}) {status}. The web remains unexplored...\"}" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } - } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; - } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); - } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); diff --git a/.github/workflows/breaking-change-checker.lock.yml b/.github/workflows/breaking-change-checker.lock.yml index 69006329ae..cd3b859053 100644 --- a/.github/workflows/breaking-change-checker.lock.yml +++ b/.github/workflows/breaking-change-checker.lock.yml @@ -6892,9 +6892,16 @@ jobs: issues: write timeout-minutes: 15 outputs: - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6907,946 +6914,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_ISSUE_TITLE_PREFIX: "[breaking-change] " GH_AW_ISSUE_LABELS: "breaking-change,automated-analysis" - GH_AW_WORKFLOW_NAME: "Breaking Change Checker" - GH_AW_TRACKER_ID: "breaking-change-checker" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e ⚠️ *Compatibility report by [{workflow_name}]({run_url})*\",\"runStarted\":\"🔬 Breaking Change Checker online! [{workflow_name}]({run_url}) is analyzing API compatibility on this {event_type}...\",\"runSuccess\":\"✅ Analysis complete! [{workflow_name}]({run_url}) has reviewed all changes. Compatibility verdict delivered! 📋\",\"runFailure\":\"🔬 Analysis interrupted! [{workflow_name}]({run_url}) {status}. Compatibility status unknown...\"}" - with: +GH_AW_ASSIGN_COPILOT=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; + summaryContent += `**Repository:** ${item.repo}\n\n`; } if (item.body) { - content += `**Body:**\n${item.body}\n\n`; + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; - } - return content; - }, - }); + summaryContent += "---\n\n"; + } + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } - } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } - } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } await core.summary.addRaw(summaryContent).write(); } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } (async () => { - await main(); + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } })(); diff --git a/.github/workflows/campaign-generator.lock.yml b/.github/workflows/campaign-generator.lock.yml index 1d87e63c3d..958c57a89b 100644 --- a/.github/workflows/campaign-generator.lock.yml +++ b/.github/workflows/campaign-generator.lock.yml @@ -6805,7 +6805,16 @@ jobs: issues: write timeout-minutes: 15 outputs: - assign_to_agent_assigned: ${{ steps.assign_to_agent.outputs.assigned }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6818,1277 +6827,175 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/assign_agent_helpers.cjs << 'EOF_b5665d23' - // @ts-check - /// - - /** - * Shared helper functions for assigning coding agents (like Copilot) to issues - * These functions use GraphQL to properly assign bot actors that cannot be assigned via gh CLI - * - * NOTE: All functions use the built-in `github` global object for authentication. - * The token must be set at the step level via the `github-token` parameter in GitHub Actions. - * This approach is required for compatibility with actions/github-script@v8. - */ - - /** - * Map agent names to their GitHub bot login names - * @type {Record} - */ - const AGENT_LOGIN_NAMES = { - copilot: "copilot-swe-agent", - }; - - /** - * Check if an assignee is a known coding agent (bot) - * @param {string} assignee - Assignee name (may include @ prefix) - * @returns {string|null} Agent name if it's a known agent, null otherwise - */ - function getAgentName(assignee) { - // Normalize: remove @ prefix if present - const normalized = assignee.startsWith("@") ? assignee.slice(1) : assignee; - - // Check if it's a known agent - if (AGENT_LOGIN_NAMES[normalized]) { - return normalized; - } - - return null; - } - - /** - * Return list of coding agent bot login names that are currently available as assignable actors - * (intersection of suggestedActors and known AGENT_LOGIN_NAMES values) - * @param {string} owner - * @param {string} repo - * @returns {Promise} - */ - async function getAvailableAgentLogins(owner, repo) { - const query = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - suggestedActors(first: 100, capabilities: CAN_BE_ASSIGNED) { - nodes { ... on Bot { login __typename } } - } - } - } - `; - try { - const response = await github.graphql(query, { owner, repo }); - const actors = response.repository?.suggestedActors?.nodes || []; - const knownValues = Object.values(AGENT_LOGIN_NAMES); - const available = []; - for (const actor of actors) { - if (actor && actor.login && knownValues.includes(actor.login)) { - available.push(actor.login); - } - } - return available.sort(); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - core.debug(`Failed to list available agent logins: ${msg}`); - return []; - } - } - - /** - * Find an agent in repository's suggested actors using GraphQL - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} agentName - Agent name (copilot) - * @returns {Promise} Agent ID or null if not found - */ - async function findAgent(owner, repo, agentName) { - const query = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - suggestedActors(first: 100, capabilities: CAN_BE_ASSIGNED) { - nodes { - ... on Bot { - id - login - __typename - } - } - } - } - } - `; - - try { - const response = await github.graphql(query, { owner, repo }); - const actors = response.repository.suggestedActors.nodes; - - const loginName = AGENT_LOGIN_NAMES[agentName]; - if (!loginName) { - core.error(`Unknown agent: ${agentName}. Supported agents: ${Object.keys(AGENT_LOGIN_NAMES).join(", ")}`); - return null; - } - - for (const actor of actors) { - if (actor.login === loginName) { - return actor.id; - } - } - - const available = actors.filter(a => a && a.login && Object.values(AGENT_LOGIN_NAMES).includes(a.login)).map(a => a.login); - - core.warning(`${agentName} coding agent (${loginName}) is not available as an assignee for this repository`); - if (available.length > 0) { - core.info(`Available assignable coding agents: ${available.join(", ")}`); - } else { - core.info("No coding agents are currently assignable in this repository."); - } - if (agentName === "copilot") { - core.info("Please visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot"); - } - return null; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to find ${agentName} agent: ${errorMessage}`); - return null; - } - } - - /** - * Get issue details (ID and current assignees) using GraphQL - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {number} issueNumber - Issue number - * @returns {Promise<{issueId: string, currentAssignees: string[]}|null>} - */ - async function getIssueDetails(owner, repo, issueNumber) { - const query = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - assignees(first: 100) { - nodes { - id - } - } - } - } - } - `; - - try { - const response = await github.graphql(query, { owner, repo, issueNumber }); - const issue = response.repository.issue; - - if (!issue || !issue.id) { - core.error("Could not get issue data"); - return null; - } - - const currentAssignees = issue.assignees.nodes.map(assignee => assignee.id); - - return { - issueId: issue.id, - currentAssignees: currentAssignees, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to get issue details: ${errorMessage}`); - return null; - } - } - - /** - * Assign agent to issue using GraphQL replaceActorsForAssignable mutation - * @param {string} issueId - GitHub issue ID - * @param {string} agentId - Agent ID - * @param {string[]} currentAssignees - List of current assignee IDs - * @param {string} agentName - Agent name for error messages - * @returns {Promise} True if successful - */ - async function assignAgentToIssue(issueId, agentId, currentAssignees, agentName) { - // Build actor IDs array - include agent and preserve other assignees - const actorIds = [agentId]; - for (const assigneeId of currentAssignees) { - if (assigneeId !== agentId) { - actorIds.push(assigneeId); - } - } - - const mutation = ` - mutation($assignableId: ID!, $actorIds: [ID!]!) { - replaceActorsForAssignable(input: { - assignableId: $assignableId, - actorIds: $actorIds - }) { - __typename - } - } - `; - - try { - core.info("Using built-in github object for mutation"); - - core.debug(`GraphQL mutation with variables: assignableId=${issueId}, actorIds=${JSON.stringify(actorIds)}`); - const response = await github.graphql(mutation, { - assignableId: issueId, - actorIds: actorIds, - }); - - if (response && response.replaceActorsForAssignable && response.replaceActorsForAssignable.__typename) { - return true; - } else { - core.error("Unexpected response from GitHub API"); - return false; - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - - // Debug: surface the raw GraphQL error structure for troubleshooting fine-grained permission issues - try { - core.debug(`Raw GraphQL error message: ${errorMessage}`); - if (error && typeof error === "object") { - // Common GraphQL error shapes: error.errors (array), error.data, error.response - const details = {}; - if (error.errors) details.errors = error.errors; - // Some libraries wrap the payload under 'response' or 'response.data' - if (error.response) details.response = error.response; - if (error.data) details.data = error.data; - // If GitHub returns an array of errors with 'type'/'message' - if (Array.isArray(error.errors)) { - details.compactMessages = error.errors.map(e => e.message).filter(Boolean); - } - const serialized = JSON.stringify(details, (_k, v) => v, 2); - if (serialized && serialized !== "{}") { - core.debug(`Raw GraphQL error details: ${serialized}`); - // Also emit non-debug version so users without ACTIONS_STEP_DEBUG can see it - core.error("Raw GraphQL error details (for troubleshooting):"); - // Split large JSON for readability - for (const line of serialized.split(/\n/)) { - if (line.trim()) core.error(line); - } - } - } - } catch (loggingErr) { - // Never fail assignment because of debug logging - core.debug(`Failed to serialize GraphQL error details: ${loggingErr instanceof Error ? loggingErr.message : String(loggingErr)}`); - } - - // Check for permission-related errors - if (errorMessage.includes("Resource not accessible by personal access token") || errorMessage.includes("Resource not accessible by integration") || errorMessage.includes("Insufficient permissions to assign")) { - // Attempt fallback mutation addAssigneesToAssignable when replaceActorsForAssignable is forbidden - core.info("Primary mutation replaceActorsForAssignable forbidden. Attempting fallback addAssigneesToAssignable..."); - try { - const fallbackMutation = ` - mutation($assignableId: ID!, $assigneeIds: [ID!]!) { - addAssigneesToAssignable(input: { - assignableId: $assignableId, - assigneeIds: $assigneeIds - }) { - clientMutationId - } - } - `; - core.info("Using built-in github object for fallback mutation"); - core.debug(`Fallback GraphQL mutation with variables: assignableId=${issueId}, assigneeIds=[${agentId}]`); - const fallbackResp = await github.graphql(fallbackMutation, { - assignableId: issueId, - assigneeIds: [agentId], - }); - if (fallbackResp && fallbackResp.addAssigneesToAssignable) { - core.info(`Fallback succeeded: agent '${agentName}' added via addAssigneesToAssignable.`); - return true; - } else { - core.warning("Fallback mutation returned unexpected response; proceeding with permission guidance."); - } - } catch (fallbackError) { - const fbMsg = fallbackError instanceof Error ? fallbackError.message : String(fallbackError); - core.error(`Fallback addAssigneesToAssignable failed: ${fbMsg}`); - } - logPermissionError(agentName); - } else { - core.error(`Failed to assign ${agentName}: ${errorMessage}`); - } - return false; - } - } - - /** - * Log detailed permission error guidance - * @param {string} agentName - Agent name for error messages - */ - function logPermissionError(agentName) { - core.error(`Failed to assign ${agentName}: Insufficient permissions`); - core.error(""); - core.error("Assigning Copilot agents requires:"); - core.error(" 1. All four workflow permissions:"); - core.error(" - actions: write"); - core.error(" - contents: write"); - core.error(" - issues: write"); - core.error(" - pull-requests: write"); - core.error(""); - core.error(" 2. A classic PAT with 'repo' scope OR fine-grained PAT with explicit Write permissions above:"); - core.error(" (Fine-grained PATs must grant repository access + write for Issues, Pull requests, Contents, Actions)"); - core.error(""); - core.error(" 3. Repository settings:"); - core.error(" - Actions must have write permissions"); - core.error(" - Go to: Settings > Actions > General > Workflow permissions"); - core.error(" - Select: 'Read and write permissions'"); - core.error(""); - core.error(" 4. Organization/Enterprise settings:"); - core.error(" - Check if your org restricts bot assignments"); - core.error(" - Verify Copilot is enabled for your repository"); - core.error(""); - core.info("For more information, see: https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/create-a-pr"); - } - - /** - * Generate permission error summary content for step summary - * @returns {string} Markdown content for permission error guidance - */ - function generatePermissionErrorSummary() { - let content = "\n### ⚠️ Permission Requirements\n\n"; - content += "Assigning Copilot agents requires **ALL** of these permissions:\n\n"; - content += "```yaml\n"; - content += "permissions:\n"; - content += " actions: write\n"; - content += " contents: write\n"; - content += " issues: write\n"; - content += " pull-requests: write\n"; - content += "```\n\n"; - content += "**Token capability note:**\n"; - content += "- Current token (PAT or GITHUB_TOKEN) lacks assignee mutation capability for this repository.\n"; - content += "- Both `replaceActorsForAssignable` and fallback `addAssigneesToAssignable` returned FORBIDDEN/Resource not accessible.\n"; - content += "- This typically means bot/user assignment requires an elevated OAuth or GitHub App installation token.\n\n"; - content += "**Recommended remediation paths:**\n"; - content += "1. Create & install a GitHub App with: Issues/Pull requests/Contents/Actions (write) → use installation token in job.\n"; - content += "2. Manual assignment: add the agent through the UI until broader token support is available.\n"; - content += "3. Open a support ticket referencing failing mutation `replaceActorsForAssignable` and repository slug.\n\n"; - content += "**Why this failed:** Fine-grained and classic PATs can update issue title (verified) but not modify assignees in this environment.\n\n"; - content += "📖 Reference: https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/create-a-pr (general agent docs)\n"; - return content; - } - - /** - * Assign an agent to an issue using GraphQL - * This is the main entry point for assigning agents from other scripts - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {number} issueNumber - Issue number - * @param {string} agentName - Agent name (e.g., "copilot") - * @returns {Promise<{success: boolean, error?: string}>} - */ - async function assignAgentToIssueByName(owner, repo, issueNumber, agentName) { - // Check if agent is supported - if (!AGENT_LOGIN_NAMES[agentName]) { - const error = `Agent "${agentName}" is not supported. Supported agents: ${Object.keys(AGENT_LOGIN_NAMES).join(", ")}`; - core.warning(error); - return { success: false, error }; - } - - try { - // Find agent using the github object authenticated via step-level github-token - core.info(`Looking for ${agentName} coding agent...`); - const agentId = await findAgent(owner, repo, agentName); - if (!agentId) { - const error = `${agentName} coding agent is not available for this repository`; - // Enrich with available agent logins - const available = await getAvailableAgentLogins(owner, repo); - const enrichedError = available.length > 0 ? `${error} (available agents: ${available.join(", ")})` : error; - return { success: false, error: enrichedError }; - } - core.info(`Found ${agentName} coding agent (ID: ${agentId})`); - - // Get issue details (ID and current assignees) via GraphQL - core.info("Getting issue details..."); - const issueDetails = await getIssueDetails(owner, repo, issueNumber); - if (!issueDetails) { - return { success: false, error: "Failed to get issue details" }; - } - - core.info(`Issue ID: ${issueDetails.issueId}`); - - // Check if agent is already assigned - if (issueDetails.currentAssignees.includes(agentId)) { - core.info(`${agentName} is already assigned to issue #${issueNumber}`); - return { success: true }; - } - - // Assign agent using GraphQL mutation - core.info(`Assigning ${agentName} coding agent to issue #${issueNumber}...`); - const success = await assignAgentToIssue(issueDetails.issueId, agentId, issueDetails.currentAssignees, agentName); - - if (!success) { - return { success: false, error: `Failed to assign ${agentName} via GraphQL` }; - } - - core.info(`Successfully assigned ${agentName} coding agent to issue #${issueNumber}`); - return { success: true }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { success: false, error: errorMessage }; - } - } - - module.exports = { - AGENT_LOGIN_NAMES, - getAgentName, - getAvailableAgentLogins, - findAgent, - getIssueDetails, - assignAgentToIssue, - logPermissionError, - generatePermissionErrorSummary, - assignAgentToIssueByName, - }; - - EOF_b5665d23 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/update_context_helpers.cjs << 'EOF_95d23c7d' - // @ts-check - /// - - /** - * Shared context helper functions for update workflows (issues, pull requests, etc.) - * - * This module provides reusable functions for determining if we're in a valid - * context for updating a specific entity type and extracting entity numbers - * from GitHub event payloads. - * - * @module update_context_helpers - */ - - /** - * Check if the current context is a valid issue context - * @param {string} eventName - GitHub event name - * @param {any} _payload - GitHub event payload (unused but kept for interface consistency) - * @returns {boolean} Whether context is valid for issue updates - */ - function isIssueContext(eventName, _payload) { - return eventName === "issues" || eventName === "issue_comment"; - } - - /** - * Get issue number from the context payload - * @param {any} payload - GitHub event payload - * @returns {number|undefined} Issue number or undefined - */ - function getIssueNumber(payload) { - return payload?.issue?.number; - } - - /** - * Check if the current context is a valid pull request context - * @param {string} eventName - GitHub event name - * @param {any} payload - GitHub event payload - * @returns {boolean} Whether context is valid for PR updates - */ - function isPRContext(eventName, payload) { - const isPR = eventName === "pull_request" || eventName === "pull_request_review" || eventName === "pull_request_review_comment" || eventName === "pull_request_target"; - - // Also check for issue_comment on a PR - const isIssueCommentOnPR = eventName === "issue_comment" && payload?.issue && payload?.issue?.pull_request; - - return isPR || !!isIssueCommentOnPR; - } - - /** - * Get pull request number from the context payload - * @param {any} payload - GitHub event payload - * @returns {number|undefined} PR number or undefined - */ - function getPRNumber(payload) { - if (payload?.pull_request) { - return payload.pull_request.number; - } - // For issue_comment events on PRs, the PR number is in issue.number - if (payload?.issue && payload?.issue?.pull_request) { - return payload.issue.number; - } - return undefined; - } - - module.exports = { - isIssueContext, - getIssueNumber, - isPRContext, - getPRNumber, - }; - - EOF_95d23c7d - cat > /tmp/gh-aw/scripts/update_runner.cjs << 'EOF_006d32d7' - // @ts-check - /// - - /** - * Shared update runner for safe-output scripts (update_issue, update_pull_request, etc.) - * - * This module depends on GitHub Actions environment globals provided by actions/github-script: - * - core: @actions/core module for logging and outputs - * - github: @octokit/rest instance for GitHub API calls - * - context: GitHub Actions context with event payload and repository info - * - * @module update_runner - */ - - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - - /** - * @typedef {Object} UpdateRunnerConfig - * @property {string} itemType - Type of item in agent output (e.g., "update_issue", "update_pull_request") - * @property {string} displayName - Human-readable name (e.g., "issue", "pull request") - * @property {string} displayNamePlural - Human-readable plural name (e.g., "issues", "pull requests") - * @property {string} numberField - Field name for explicit number (e.g., "issue_number", "pull_request_number") - * @property {string} outputNumberKey - Output key for number (e.g., "issue_number", "pull_request_number") - * @property {string} outputUrlKey - Output key for URL (e.g., "issue_url", "pull_request_url") - * @property {(eventName: string, payload: any) => boolean} isValidContext - Function to check if context is valid - * @property {(payload: any) => number|undefined} getContextNumber - Function to get number from context payload - * @property {boolean} supportsStatus - Whether this type supports status updates - * @property {boolean} supportsOperation - Whether this type supports operation (append/prepend/replace) - * @property {(item: any, index: number) => string} renderStagedItem - Function to render item for staged preview - * @property {(github: any, context: any, targetNumber: number, updateData: any) => Promise} executeUpdate - Function to execute the update API call - * @property {(result: any) => string} getSummaryLine - Function to generate summary line for an updated item - */ - - /** - * Resolve the target number for an update operation - * @param {Object} params - Resolution parameters - * @param {string} params.updateTarget - Target configuration ("triggering", "*", or explicit number) - * @param {any} params.item - Update item with optional explicit number field - * @param {string} params.numberField - Field name for explicit number - * @param {boolean} params.isValidContext - Whether current context is valid - * @param {number|undefined} params.contextNumber - Number from triggering context - * @param {string} params.displayName - Display name for error messages - * @returns {{success: true, number: number} | {success: false, error: string}} - */ - function resolveTargetNumber(params) { - const { updateTarget, item, numberField, isValidContext, contextNumber, displayName } = params; - - if (updateTarget === "*") { - // For target "*", we need an explicit number from the update item - const explicitNumber = item[numberField]; - if (explicitNumber) { - const parsed = parseInt(explicitNumber, 10); - if (isNaN(parsed) || parsed <= 0) { - return { success: false, error: `Invalid ${numberField} specified: ${explicitNumber}` }; - } - return { success: true, number: parsed }; - } else { - return { success: false, error: `Target is "*" but no ${numberField} specified in update item` }; - } - } else if (updateTarget && updateTarget !== "triggering") { - // Explicit number specified in target - const parsed = parseInt(updateTarget, 10); - if (isNaN(parsed) || parsed <= 0) { - return { success: false, error: `Invalid ${displayName} number in target configuration: ${updateTarget}` }; - } - return { success: true, number: parsed }; - } else { - // Default behavior: use triggering context - if (isValidContext && contextNumber) { - return { success: true, number: contextNumber }; - } - return { success: false, error: `Could not determine ${displayName} number` }; - } - } - - /** - * Build update data based on allowed fields and provided values - * @param {Object} params - Build parameters - * @param {any} params.item - Update item with field values - * @param {boolean} params.canUpdateStatus - Whether status updates are allowed - * @param {boolean} params.canUpdateTitle - Whether title updates are allowed - * @param {boolean} params.canUpdateBody - Whether body updates are allowed - * @param {boolean} params.supportsStatus - Whether this type supports status - * @returns {{hasUpdates: boolean, updateData: any, logMessages: string[]}} - */ - function buildUpdateData(params) { - const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, supportsStatus } = params; - - /** @type {any} */ - const updateData = {}; - let hasUpdates = false; - const logMessages = []; - - // Handle status update (only for types that support it, like issues) - if (supportsStatus && canUpdateStatus && item.status !== undefined) { - if (item.status === "open" || item.status === "closed") { - updateData.state = item.status; - hasUpdates = true; - logMessages.push(`Will update status to: ${item.status}`); - } else { - logMessages.push(`Invalid status value: ${item.status}. Must be 'open' or 'closed'`); - } - } - - // Handle title update - let titleForDedup = null; - if (canUpdateTitle && item.title !== undefined) { - const trimmedTitle = typeof item.title === "string" ? item.title.trim() : ""; - if (trimmedTitle.length > 0) { - updateData.title = trimmedTitle; - titleForDedup = trimmedTitle; - hasUpdates = true; - logMessages.push(`Will update title to: ${trimmedTitle}`); - } else { - logMessages.push("Invalid title value: must be a non-empty string"); - } - } - - // Handle body update (with title deduplication) - if (canUpdateBody && item.body !== undefined) { - if (typeof item.body === "string") { - let processedBody = item.body; - - // If we're updating the title at the same time, remove duplicate title from body - if (titleForDedup) { - processedBody = removeDuplicateTitleFromDescription(titleForDedup, processedBody); - } - - updateData.body = processedBody; - hasUpdates = true; - logMessages.push(`Will update body (length: ${processedBody.length})`); - } else { - logMessages.push("Invalid body value: must be a string"); - } - } - - return { hasUpdates, updateData, logMessages }; - } - - /** - * Run the update workflow with the provided configuration - * @param {UpdateRunnerConfig} config - Configuration for the update runner - * @returns {Promise} Array of updated items or undefined - */ - async function runUpdateWorkflow(config) { - const { itemType, displayName, displayNamePlural, numberField, outputNumberKey, outputUrlKey, isValidContext, getContextNumber, supportsStatus, supportsOperation, renderStagedItem, executeUpdate, getSummaryLine } = config; - - // Check if we're in staged mode - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - - const result = loadAgentOutput(); - if (!result.success) { - return; - } - - // Find all update items - const updateItems = result.items.filter(/** @param {any} item */ item => item.type === itemType); - if (updateItems.length === 0) { - core.info(`No ${itemType} items found in agent output`); - return; - } - - core.info(`Found ${updateItems.length} ${itemType} item(s)`); - - // If in staged mode, emit step summary instead of updating - if (isStaged) { - await generateStagedPreview({ - title: `Update ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}`, - description: `The following ${displayName} updates would be applied if staged mode was disabled:`, - items: updateItems, - renderItem: renderStagedItem, - }); - return; - } - - // Get the configuration from environment variables - const updateTarget = process.env.GH_AW_UPDATE_TARGET || "triggering"; - const canUpdateStatus = process.env.GH_AW_UPDATE_STATUS === "true"; - const canUpdateTitle = process.env.GH_AW_UPDATE_TITLE === "true"; - const canUpdateBody = process.env.GH_AW_UPDATE_BODY === "true"; - - core.info(`Update target configuration: ${updateTarget}`); - if (supportsStatus) { - core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}`); - } else { - core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}`); - } - - // Check context validity - const contextIsValid = isValidContext(context.eventName, context.payload); - const contextNumber = getContextNumber(context.payload); - - // Validate context based on target configuration - if (updateTarget === "triggering" && !contextIsValid) { - core.info(`Target is "triggering" but not running in ${displayName} context, skipping ${displayName} update`); - return; - } - - const updatedItems = []; - - // Process each update item - for (let i = 0; i < updateItems.length; i++) { - const updateItem = updateItems[i]; - core.info(`Processing ${itemType} item ${i + 1}/${updateItems.length}`); - - // Resolve target number - const targetResult = resolveTargetNumber({ - updateTarget, - item: updateItem, - numberField, - isValidContext: contextIsValid, - contextNumber, - displayName, - }); - - if (!targetResult.success) { - core.info(targetResult.error); - continue; - } - - const targetNumber = targetResult.number; - core.info(`Updating ${displayName} #${targetNumber}`); - - // Build update data - const { hasUpdates, updateData, logMessages } = buildUpdateData({ - item: updateItem, - canUpdateStatus, - canUpdateTitle, - canUpdateBody, - supportsStatus, - }); - - // Log all messages - for (const msg of logMessages) { - core.info(msg); - } - - // Handle body operation for types that support it (like PRs with append/prepend) - if (supportsOperation && canUpdateBody && updateItem.body !== undefined && typeof updateItem.body === "string") { - // The body was already added by buildUpdateData, but we need to handle operations - // This will be handled by the executeUpdate function for PR-specific logic - updateData._operation = updateItem.operation || "append"; - updateData._rawBody = updateItem.body; - } - - if (!hasUpdates) { - core.info("No valid updates to apply for this item"); - continue; - } - - try { - // Execute the update using the provided function - const updatedItem = await executeUpdate(github, context, targetNumber, updateData); - core.info(`Updated ${displayName} #${updatedItem.number}: ${updatedItem.html_url}`); - updatedItems.push(updatedItem); - - // Set output for the last updated item (for backward compatibility) - if (i === updateItems.length - 1) { - core.setOutput(outputNumberKey, updatedItem.number); - core.setOutput(outputUrlKey, updatedItem.html_url); - } - } catch (error) { - core.error(`✗ Failed to update ${displayName} #${targetNumber}: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } - } - - // Write summary for all updated items - if (updatedItems.length > 0) { - let summaryContent = `\n\n## Updated ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}\n`; - for (const item of updatedItems) { - summaryContent += getSummaryLine(item); - } - await core.summary.addRaw(summaryContent).write(); - } - - core.info(`Successfully updated ${updatedItems.length} ${displayName}(s)`); - return updatedItems; - } - - /** - * @typedef {Object} RenderStagedItemConfig - * @property {string} entityName - Display name for the entity (e.g., "Issue", "Pull Request") - * @property {string} numberField - Field name for the target number (e.g., "issue_number", "pull_request_number") - * @property {string} targetLabel - Label for the target (e.g., "Target Issue:", "Target PR:") - * @property {string} currentTargetText - Text when targeting current entity (e.g., "Current issue", "Current pull request") - * @property {boolean} [includeOperation=false] - Whether to include operation field for body updates - */ - - /** - * Create a render function for staged preview items - * @param {RenderStagedItemConfig} config - Configuration for the renderer - * @returns {(item: any, index: number) => string} Render function - */ - function createRenderStagedItem(config) { - const { entityName, numberField, targetLabel, currentTargetText, includeOperation = false } = config; - - return function renderStagedItem(item, index) { - let content = `#### ${entityName} Update ${index + 1}\n`; - if (item[numberField]) { - content += `**${targetLabel}** #${item[numberField]}\n\n`; - } else { - content += `**Target:** ${currentTargetText}\n\n`; - } - - if (item.title !== undefined) { - content += `**New Title:** ${item.title}\n\n`; - } - if (item.body !== undefined) { - if (includeOperation) { - const operation = item.operation || "append"; - content += `**Operation:** ${operation}\n`; - content += `**Body Content:**\n${item.body}\n\n`; - } else { - content += `**New Body:**\n${item.body}\n\n`; - } - } - if (item.status !== undefined) { - content += `**New Status:** ${item.status}\n\n`; - } - return content; - }; - } - - /** - * @typedef {Object} SummaryLineConfig - * @property {string} entityPrefix - Prefix for the summary line (e.g., "Issue", "PR") - */ - - /** - * Create a summary line generator function - * @param {SummaryLineConfig} config - Configuration for the summary generator - * @returns {(item: any) => string} Summary line generator function - */ - function createGetSummaryLine(config) { - const { entityPrefix } = config; - - return function getSummaryLine(item) { - return `- ${entityPrefix} #${item.number}: [${item.title}](${item.html_url})\n`; - }; - } - - module.exports = { - runUpdateWorkflow, - resolveTargetNumber, - buildUpdateData, - createRenderStagedItem, - createGetSummaryLine, - }; - - EOF_006d32d7 - - name: Assign To Agent - id: assign_to_agent - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'assign_to_agent')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Campaign Generator" - GH_AW_ENGINE_ID: "copilot" with: - github-token: ${{ secrets.GH_AW_AGENT_TOKEN }} + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { AGENT_LOGIN_NAMES, getAvailableAgentLogins, findAgent, getIssueDetails, assignAgentToIssue, generatePermissionErrorSummary } = require('/tmp/gh-aw/scripts/assign_agent_helpers.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const assignItems = result.items.filter(item => item.type === "assign_to_agent"); - if (assignItems.length === 0) { - core.info("No assign_to_agent items found in agent output"); - return; - } - core.info(`Found ${assignItems.length} assign_to_agent item(s)`); - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - await generateStagedPreview({ - title: "Assign to Agent", - description: "The following agent assignments would be made if staged mode was disabled:", - items: assignItems, - renderItem: item => { - let content = `**Issue:** #${item.issue_number}\n`; - content += `**Agent:** ${item.agent || "copilot"}\n`; - content += "\n"; - return content; - }, - }); - return; - } - const defaultAgent = process.env.GH_AW_AGENT_DEFAULT?.trim() || "copilot"; - core.info(`Default agent: ${defaultAgent}`); - const maxCountEnv = process.env.GH_AW_AGENT_MAX_COUNT; - const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 1; - if (isNaN(maxCount) || maxCount < 1) { - core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; + } + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - core.info(`Max count: ${maxCount}`); - const itemsToProcess = assignItems.slice(0, maxCount); - if (assignItems.length > maxCount) { - core.warning(`Found ${assignItems.length} agent assignments, but max is ${maxCount}. Processing first ${maxCount}.`); - } - const targetRepoEnv = process.env.GH_AW_TARGET_REPO?.trim(); - let targetOwner = context.repo.owner; - let targetRepo = context.repo.repo; - if (targetRepoEnv) { - const parts = targetRepoEnv.split("/"); - if (parts.length === 2) { - targetOwner = parts[0]; - targetRepo = parts[1]; - core.info(`Using target repository: ${targetOwner}/${targetRepo}`); - } else { - core.warning(`Invalid target-repo format: ${targetRepoEnv}. Expected owner/repo. Using current repository.`); - } + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const agentCache = {}; - const results = []; - for (const item of itemsToProcess) { - const issueNumber = typeof item.issue_number === "number" ? item.issue_number : parseInt(String(item.issue_number), 10); - const agentName = item.agent || defaultAgent; - if (isNaN(issueNumber) || issueNumber <= 0) { - core.error(`Invalid issue_number: ${item.issue_number}`); - continue; - } - if (!AGENT_LOGIN_NAMES[agentName]) { - core.warning(`Agent "${agentName}" is not supported. Supported agents: ${Object.keys(AGENT_LOGIN_NAMES).join(", ")}`); - results.push({ - issue_number: issueNumber, - agent: agentName, - success: false, - error: `Unsupported agent: ${agentName}`, - }); - continue; - } - try { - let agentId = agentCache[agentName]; - if (!agentId) { - core.info(`Looking for ${agentName} coding agent...`); - agentId = await findAgent(targetOwner, targetRepo, agentName); - if (!agentId) { - throw new Error(`${agentName} coding agent is not available for this repository`); - } - agentCache[agentName] = agentId; - core.info(`Found ${agentName} coding agent (ID: ${agentId})`); - } - core.info("Getting issue details..."); - const issueDetails = await getIssueDetails(targetOwner, targetRepo, issueNumber); - if (!issueDetails) { - throw new Error("Failed to get issue details"); - } - core.info(`Issue ID: ${issueDetails.issueId}`); - if (issueDetails.currentAssignees.includes(agentId)) { - core.info(`${agentName} is already assigned to issue #${issueNumber}`); - results.push({ - issue_number: issueNumber, - agent: agentName, - success: true, - }); - continue; - } - core.info(`Assigning ${agentName} coding agent to issue #${issueNumber}...`); - const success = await assignAgentToIssue(issueDetails.issueId, agentId, issueDetails.currentAssignees, agentName); - if (!success) { - throw new Error(`Failed to assign ${agentName} via GraphQL`); - } - core.info(`Successfully assigned ${agentName} coding agent to issue #${issueNumber}`); - results.push({ - issue_number: issueNumber, - agent: agentName, - success: true, - }); - } catch (error) { - let errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("coding agent is not available for this repository")) { - try { - const available = await getAvailableAgentLogins(targetOwner, targetRepo); - if (available.length > 0) { - errorMessage += ` (available agents: ${available.join(", ")})`; - } - } catch (e) { - core.debug("Failed to enrich unavailable agent message with available list"); + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } - core.error(`Failed to assign agent "${agentName}" to issue #${issueNumber}: ${errorMessage}`); - results.push({ - issue_number: issueNumber, - agent: agentName, - success: false, - error: errorMessage, - }); } } - const successCount = results.filter(r => r.success).length; - const failureCount = results.filter(r => !r.success).length; - let summaryContent = "## Agent Assignment\n\n"; - if (successCount > 0) { - summaryContent += `✅ Successfully assigned ${successCount} agent(s):\n\n`; - for (const result of results.filter(r => r.success)) { - summaryContent += `- Issue #${result.issue_number} → Agent: ${result.agent}\n`; + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } - summaryContent += "\n"; } - if (failureCount > 0) { - summaryContent += `❌ Failed to assign ${failureCount} agent(s):\n\n`; - for (const result of results.filter(r => !r.success)) { - summaryContent += `- Issue #${result.issue_number} → Agent: ${result.agent}: ${result.error}\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - const hasPermissionError = results.some(r => !r.success && r.error && (r.error.includes("Resource not accessible") || r.error.includes("Insufficient permissions"))); - if (hasPermissionError) { - summaryContent += generatePermissionErrorSummary(); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } + await core.summary.addRaw(summaryContent).write(); } - await core.summary.addRaw(summaryContent).write(); - const assignedAgents = results - .filter(r => r.success) - .map(r => `${r.issue_number}:${r.agent}`) - .join("\n"); - core.setOutput("assigned_agents", assignedAgents); - if (failureCount > 0) { - core.setFailed(`Failed to assign ${failureCount} agent(s)`); - } + core.info(`Completed processing ${result.items.length} safe output item(s)`); } (async () => { - await main(); + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } })(); - - name: Update Issue - id: update_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'update_issue')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Campaign Generator" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { runUpdateWorkflow, createRenderStagedItem, createGetSummaryLine } = require('/tmp/gh-aw/scripts/update_runner.cjs'); - const { isIssueContext, getIssueNumber } = require('/tmp/gh-aw/scripts/update_context_helpers.cjs'); - const renderStagedItem = createRenderStagedItem({ - entityName: "Issue", - numberField: "issue_number", - targetLabel: "Target Issue:", - currentTargetText: "Current issue", - includeOperation: false, - }); - async function executeIssueUpdate(github, context, issueNumber, updateData) { - const { _operation, _rawBody, ...apiData } = updateData; - const { data: issue } = await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - ...apiData, - }); - return issue; - } - const getSummaryLine = createGetSummaryLine({ - entityPrefix: "Issue", - }); - async function main() { - return await runUpdateWorkflow({ - itemType: "update_issue", - displayName: "issue", - displayNamePlural: "issues", - numberField: "issue_number", - outputNumberKey: "issue_number", - outputUrlKey: "issue_url", - isValidContext: isIssueContext, - getContextNumber: getIssueNumber, - supportsStatus: true, - supportsOperation: false, - renderStagedItem, - executeUpdate: executeIssueUpdate, - getSummaryLine, - }); - } - (async () => { await main(); })(); diff --git a/.github/workflows/changeset.lock.yml b/.github/workflows/changeset.lock.yml index 798374cd43..717257447a 100644 --- a/.github/workflows/changeset.lock.yml +++ b/.github/workflows/changeset.lock.yml @@ -7235,7 +7235,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - push_to_pull_request_branch_commit_url: ${{ steps.push_to_pull_request_branch.outputs.commit_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7260,1579 +7269,177 @@ jobs: permission-contents: write permission-issues: write permission-pull-requests: write - - name: Setup JavaScript files - id: setup_scripts - shell: bash - run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - cat > /tmp/gh-aw/scripts/update_context_helpers.cjs << 'EOF_95d23c7d' - // @ts-check - /// - - /** - * Shared context helper functions for update workflows (issues, pull requests, etc.) - * - * This module provides reusable functions for determining if we're in a valid - * context for updating a specific entity type and extracting entity numbers - * from GitHub event payloads. - * - * @module update_context_helpers - */ - - /** - * Check if the current context is a valid issue context - * @param {string} eventName - GitHub event name - * @param {any} _payload - GitHub event payload (unused but kept for interface consistency) - * @returns {boolean} Whether context is valid for issue updates - */ - function isIssueContext(eventName, _payload) { - return eventName === "issues" || eventName === "issue_comment"; - } - - /** - * Get issue number from the context payload - * @param {any} payload - GitHub event payload - * @returns {number|undefined} Issue number or undefined - */ - function getIssueNumber(payload) { - return payload?.issue?.number; - } - - /** - * Check if the current context is a valid pull request context - * @param {string} eventName - GitHub event name - * @param {any} payload - GitHub event payload - * @returns {boolean} Whether context is valid for PR updates - */ - function isPRContext(eventName, payload) { - const isPR = eventName === "pull_request" || eventName === "pull_request_review" || eventName === "pull_request_review_comment" || eventName === "pull_request_target"; - - // Also check for issue_comment on a PR - const isIssueCommentOnPR = eventName === "issue_comment" && payload?.issue && payload?.issue?.pull_request; - - return isPR || !!isIssueCommentOnPR; - } - - /** - * Get pull request number from the context payload - * @param {any} payload - GitHub event payload - * @returns {number|undefined} PR number or undefined - */ - function getPRNumber(payload) { - if (payload?.pull_request) { - return payload.pull_request.number; - } - // For issue_comment events on PRs, the PR number is in issue.number - if (payload?.issue && payload?.issue?.pull_request) { - return payload.issue.number; - } - return undefined; - } - - module.exports = { - isIssueContext, - getIssueNumber, - isPRContext, - getPRNumber, - }; - - EOF_95d23c7d - cat > /tmp/gh-aw/scripts/update_pr_description_helpers.cjs << 'EOF_d0693c3b' - // @ts-check - /// - - /** - * Helper functions for updating pull request descriptions - * Handles append, prepend, replace, and replace-island operations - * @module update_pr_description_helpers - */ - - const { getFooterMessage } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - - /** - * Build the AI footer with workflow attribution - * Uses the messages system to support custom templates from frontmatter - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} AI attribution footer - */ - function buildAIFooter(workflowName, runUrl) { - return "\n\n" + getFooterMessage({ workflowName, runUrl }); - } - - /** - * Build the island start marker for replace-island mode - * @param {number} runId - Workflow run ID - * @returns {string} Island start marker - */ - function buildIslandStartMarker(runId) { - return ``; - } - - /** - * Build the island end marker for replace-island mode - * @param {number} runId - Workflow run ID - * @returns {string} Island end marker - */ - function buildIslandEndMarker(runId) { - return ``; - } - - /** - * Find and extract island content from body - * @param {string} body - The body content to search - * @param {number} runId - Workflow run ID - * @returns {{found: boolean, startIndex: number, endIndex: number}} Island location info - */ - function findIsland(body, runId) { - const startMarker = buildIslandStartMarker(runId); - const endMarker = buildIslandEndMarker(runId); - - const startIndex = body.indexOf(startMarker); - if (startIndex === -1) { - return { found: false, startIndex: -1, endIndex: -1 }; - } - - const endIndex = body.indexOf(endMarker, startIndex); - if (endIndex === -1) { - return { found: false, startIndex: -1, endIndex: -1 }; - } - - return { found: true, startIndex, endIndex: endIndex + endMarker.length }; - } - - /** - * Update PR body with the specified operation - * @param {Object} params - Update parameters - * @param {string} params.currentBody - Current PR body content - * @param {string} params.newContent - New content to add/replace - * @param {string} params.operation - Operation type: "append", "prepend", "replace", or "replace-island" - * @param {string} params.workflowName - Name of the workflow - * @param {string} params.runUrl - URL of the workflow run - * @param {number} params.runId - Workflow run ID - * @returns {string} Updated body content - */ - function updatePRBody(params) { - const { currentBody, newContent, operation, workflowName, runUrl, runId } = params; - const aiFooter = buildAIFooter(workflowName, runUrl); - - if (operation === "replace") { - // Replace: just use the new content as-is - core.info("Operation: replace (full body replacement)"); - return newContent; - } - - if (operation === "replace-island") { - // Try to find existing island for this run ID - const island = findIsland(currentBody, runId); - - if (island.found) { - // Replace the island content - core.info(`Operation: replace-island (updating existing island for run ${runId})`); - const startMarker = buildIslandStartMarker(runId); - const endMarker = buildIslandEndMarker(runId); - const islandContent = `${startMarker}\n${newContent}${aiFooter}\n${endMarker}`; - - const before = currentBody.substring(0, island.startIndex); - const after = currentBody.substring(island.endIndex); - return before + islandContent + after; - } else { - // Island not found, fall back to append mode - core.info(`Operation: replace-island (island not found for run ${runId}, falling back to append)`); - const startMarker = buildIslandStartMarker(runId); - const endMarker = buildIslandEndMarker(runId); - const islandContent = `${startMarker}\n${newContent}${aiFooter}\n${endMarker}`; - const appendSection = `\n\n---\n\n${islandContent}`; - return currentBody + appendSection; - } - } - - if (operation === "prepend") { - // Prepend: add content, AI footer, and horizontal line at the start - core.info("Operation: prepend (add to start with separator)"); - const prependSection = `${newContent}${aiFooter}\n\n---\n\n`; - return prependSection + currentBody; - } - - // Default to append - core.info("Operation: append (add to end with separator)"); - const appendSection = `\n\n---\n\n${newContent}${aiFooter}`; - return currentBody + appendSection; - } - - module.exports = { - buildAIFooter, - buildIslandStartMarker, - buildIslandEndMarker, - findIsland, - updatePRBody, - }; - - EOF_d0693c3b - cat > /tmp/gh-aw/scripts/update_runner.cjs << 'EOF_006d32d7' - // @ts-check - /// - - /** - * Shared update runner for safe-output scripts (update_issue, update_pull_request, etc.) - * - * This module depends on GitHub Actions environment globals provided by actions/github-script: - * - core: @actions/core module for logging and outputs - * - github: @octokit/rest instance for GitHub API calls - * - context: GitHub Actions context with event payload and repository info - * - * @module update_runner - */ - - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - - /** - * @typedef {Object} UpdateRunnerConfig - * @property {string} itemType - Type of item in agent output (e.g., "update_issue", "update_pull_request") - * @property {string} displayName - Human-readable name (e.g., "issue", "pull request") - * @property {string} displayNamePlural - Human-readable plural name (e.g., "issues", "pull requests") - * @property {string} numberField - Field name for explicit number (e.g., "issue_number", "pull_request_number") - * @property {string} outputNumberKey - Output key for number (e.g., "issue_number", "pull_request_number") - * @property {string} outputUrlKey - Output key for URL (e.g., "issue_url", "pull_request_url") - * @property {(eventName: string, payload: any) => boolean} isValidContext - Function to check if context is valid - * @property {(payload: any) => number|undefined} getContextNumber - Function to get number from context payload - * @property {boolean} supportsStatus - Whether this type supports status updates - * @property {boolean} supportsOperation - Whether this type supports operation (append/prepend/replace) - * @property {(item: any, index: number) => string} renderStagedItem - Function to render item for staged preview - * @property {(github: any, context: any, targetNumber: number, updateData: any) => Promise} executeUpdate - Function to execute the update API call - * @property {(result: any) => string} getSummaryLine - Function to generate summary line for an updated item - */ - - /** - * Resolve the target number for an update operation - * @param {Object} params - Resolution parameters - * @param {string} params.updateTarget - Target configuration ("triggering", "*", or explicit number) - * @param {any} params.item - Update item with optional explicit number field - * @param {string} params.numberField - Field name for explicit number - * @param {boolean} params.isValidContext - Whether current context is valid - * @param {number|undefined} params.contextNumber - Number from triggering context - * @param {string} params.displayName - Display name for error messages - * @returns {{success: true, number: number} | {success: false, error: string}} - */ - function resolveTargetNumber(params) { - const { updateTarget, item, numberField, isValidContext, contextNumber, displayName } = params; - - if (updateTarget === "*") { - // For target "*", we need an explicit number from the update item - const explicitNumber = item[numberField]; - if (explicitNumber) { - const parsed = parseInt(explicitNumber, 10); - if (isNaN(parsed) || parsed <= 0) { - return { success: false, error: `Invalid ${numberField} specified: ${explicitNumber}` }; - } - return { success: true, number: parsed }; - } else { - return { success: false, error: `Target is "*" but no ${numberField} specified in update item` }; - } - } else if (updateTarget && updateTarget !== "triggering") { - // Explicit number specified in target - const parsed = parseInt(updateTarget, 10); - if (isNaN(parsed) || parsed <= 0) { - return { success: false, error: `Invalid ${displayName} number in target configuration: ${updateTarget}` }; - } - return { success: true, number: parsed }; - } else { - // Default behavior: use triggering context - if (isValidContext && contextNumber) { - return { success: true, number: contextNumber }; - } - return { success: false, error: `Could not determine ${displayName} number` }; - } - } - - /** - * Build update data based on allowed fields and provided values - * @param {Object} params - Build parameters - * @param {any} params.item - Update item with field values - * @param {boolean} params.canUpdateStatus - Whether status updates are allowed - * @param {boolean} params.canUpdateTitle - Whether title updates are allowed - * @param {boolean} params.canUpdateBody - Whether body updates are allowed - * @param {boolean} params.supportsStatus - Whether this type supports status - * @returns {{hasUpdates: boolean, updateData: any, logMessages: string[]}} - */ - function buildUpdateData(params) { - const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, supportsStatus } = params; - - /** @type {any} */ - const updateData = {}; - let hasUpdates = false; - const logMessages = []; - - // Handle status update (only for types that support it, like issues) - if (supportsStatus && canUpdateStatus && item.status !== undefined) { - if (item.status === "open" || item.status === "closed") { - updateData.state = item.status; - hasUpdates = true; - logMessages.push(`Will update status to: ${item.status}`); - } else { - logMessages.push(`Invalid status value: ${item.status}. Must be 'open' or 'closed'`); - } - } - - // Handle title update - let titleForDedup = null; - if (canUpdateTitle && item.title !== undefined) { - const trimmedTitle = typeof item.title === "string" ? item.title.trim() : ""; - if (trimmedTitle.length > 0) { - updateData.title = trimmedTitle; - titleForDedup = trimmedTitle; - hasUpdates = true; - logMessages.push(`Will update title to: ${trimmedTitle}`); - } else { - logMessages.push("Invalid title value: must be a non-empty string"); - } - } - - // Handle body update (with title deduplication) - if (canUpdateBody && item.body !== undefined) { - if (typeof item.body === "string") { - let processedBody = item.body; - - // If we're updating the title at the same time, remove duplicate title from body - if (titleForDedup) { - processedBody = removeDuplicateTitleFromDescription(titleForDedup, processedBody); - } - - updateData.body = processedBody; - hasUpdates = true; - logMessages.push(`Will update body (length: ${processedBody.length})`); - } else { - logMessages.push("Invalid body value: must be a string"); - } - } - - return { hasUpdates, updateData, logMessages }; - } - - /** - * Run the update workflow with the provided configuration - * @param {UpdateRunnerConfig} config - Configuration for the update runner - * @returns {Promise} Array of updated items or undefined - */ - async function runUpdateWorkflow(config) { - const { itemType, displayName, displayNamePlural, numberField, outputNumberKey, outputUrlKey, isValidContext, getContextNumber, supportsStatus, supportsOperation, renderStagedItem, executeUpdate, getSummaryLine } = config; - - // Check if we're in staged mode - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - - const result = loadAgentOutput(); - if (!result.success) { - return; - } - - // Find all update items - const updateItems = result.items.filter(/** @param {any} item */ item => item.type === itemType); - if (updateItems.length === 0) { - core.info(`No ${itemType} items found in agent output`); - return; - } - - core.info(`Found ${updateItems.length} ${itemType} item(s)`); - - // If in staged mode, emit step summary instead of updating - if (isStaged) { - await generateStagedPreview({ - title: `Update ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}`, - description: `The following ${displayName} updates would be applied if staged mode was disabled:`, - items: updateItems, - renderItem: renderStagedItem, - }); - return; - } - - // Get the configuration from environment variables - const updateTarget = process.env.GH_AW_UPDATE_TARGET || "triggering"; - const canUpdateStatus = process.env.GH_AW_UPDATE_STATUS === "true"; - const canUpdateTitle = process.env.GH_AW_UPDATE_TITLE === "true"; - const canUpdateBody = process.env.GH_AW_UPDATE_BODY === "true"; - - core.info(`Update target configuration: ${updateTarget}`); - if (supportsStatus) { - core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}`); - } else { - core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}`); - } - - // Check context validity - const contextIsValid = isValidContext(context.eventName, context.payload); - const contextNumber = getContextNumber(context.payload); - - // Validate context based on target configuration - if (updateTarget === "triggering" && !contextIsValid) { - core.info(`Target is "triggering" but not running in ${displayName} context, skipping ${displayName} update`); - return; - } - - const updatedItems = []; - - // Process each update item - for (let i = 0; i < updateItems.length; i++) { - const updateItem = updateItems[i]; - core.info(`Processing ${itemType} item ${i + 1}/${updateItems.length}`); - - // Resolve target number - const targetResult = resolveTargetNumber({ - updateTarget, - item: updateItem, - numberField, - isValidContext: contextIsValid, - contextNumber, - displayName, - }); - - if (!targetResult.success) { - core.info(targetResult.error); - continue; - } - - const targetNumber = targetResult.number; - core.info(`Updating ${displayName} #${targetNumber}`); - - // Build update data - const { hasUpdates, updateData, logMessages } = buildUpdateData({ - item: updateItem, - canUpdateStatus, - canUpdateTitle, - canUpdateBody, - supportsStatus, - }); - - // Log all messages - for (const msg of logMessages) { - core.info(msg); - } - - // Handle body operation for types that support it (like PRs with append/prepend) - if (supportsOperation && canUpdateBody && updateItem.body !== undefined && typeof updateItem.body === "string") { - // The body was already added by buildUpdateData, but we need to handle operations - // This will be handled by the executeUpdate function for PR-specific logic - updateData._operation = updateItem.operation || "append"; - updateData._rawBody = updateItem.body; - } - - if (!hasUpdates) { - core.info("No valid updates to apply for this item"); - continue; - } - - try { - // Execute the update using the provided function - const updatedItem = await executeUpdate(github, context, targetNumber, updateData); - core.info(`Updated ${displayName} #${updatedItem.number}: ${updatedItem.html_url}`); - updatedItems.push(updatedItem); - - // Set output for the last updated item (for backward compatibility) - if (i === updateItems.length - 1) { - core.setOutput(outputNumberKey, updatedItem.number); - core.setOutput(outputUrlKey, updatedItem.html_url); - } - } catch (error) { - core.error(`✗ Failed to update ${displayName} #${targetNumber}: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } - } - - // Write summary for all updated items - if (updatedItems.length > 0) { - let summaryContent = `\n\n## Updated ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}\n`; - for (const item of updatedItems) { - summaryContent += getSummaryLine(item); - } - await core.summary.addRaw(summaryContent).write(); - } - - core.info(`Successfully updated ${updatedItems.length} ${displayName}(s)`); - return updatedItems; - } - - /** - * @typedef {Object} RenderStagedItemConfig - * @property {string} entityName - Display name for the entity (e.g., "Issue", "Pull Request") - * @property {string} numberField - Field name for the target number (e.g., "issue_number", "pull_request_number") - * @property {string} targetLabel - Label for the target (e.g., "Target Issue:", "Target PR:") - * @property {string} currentTargetText - Text when targeting current entity (e.g., "Current issue", "Current pull request") - * @property {boolean} [includeOperation=false] - Whether to include operation field for body updates - */ - - /** - * Create a render function for staged preview items - * @param {RenderStagedItemConfig} config - Configuration for the renderer - * @returns {(item: any, index: number) => string} Render function - */ - function createRenderStagedItem(config) { - const { entityName, numberField, targetLabel, currentTargetText, includeOperation = false } = config; - - return function renderStagedItem(item, index) { - let content = `#### ${entityName} Update ${index + 1}\n`; - if (item[numberField]) { - content += `**${targetLabel}** #${item[numberField]}\n\n`; - } else { - content += `**Target:** ${currentTargetText}\n\n`; - } - - if (item.title !== undefined) { - content += `**New Title:** ${item.title}\n\n`; - } - if (item.body !== undefined) { - if (includeOperation) { - const operation = item.operation || "append"; - content += `**Operation:** ${operation}\n`; - content += `**Body Content:**\n${item.body}\n\n`; - } else { - content += `**New Body:**\n${item.body}\n\n`; - } - } - if (item.status !== undefined) { - content += `**New Status:** ${item.status}\n\n`; - } - return content; - }; - } - - /** - * @typedef {Object} SummaryLineConfig - * @property {string} entityPrefix - Prefix for the summary line (e.g., "Issue", "PR") - */ - - /** - * Create a summary line generator function - * @param {SummaryLineConfig} config - Configuration for the summary generator - * @returns {(item: any) => string} Summary line generator function - */ - function createGetSummaryLine(config) { - const { entityPrefix } = config; - - return function getSummaryLine(item) { - return `- ${entityPrefix} #${item.number}: [${item.title}](${item.html_url})\n`; - }; - } - - module.exports = { - runUpdateWorkflow, - resolveTargetNumber, - buildUpdateData, - createRenderStagedItem, - createGetSummaryLine, - }; - - EOF_006d32d7 - - name: Update Pull Request - id: update_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'update_pull_request')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Changeset Generator" - GH_AW_ENGINE_ID: "codex" - GH_AW_ENGINE_MODEL: "gpt-5-mini" + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { runUpdateWorkflow, createRenderStagedItem, createGetSummaryLine } = require('/tmp/gh-aw/scripts/update_runner.cjs'); - const { updatePRBody } = require('/tmp/gh-aw/scripts/update_pr_description_helpers.cjs'); - const { isPRContext, getPRNumber } = require('/tmp/gh-aw/scripts/update_context_helpers.cjs'); - const renderStagedItem = createRenderStagedItem({ - entityName: "Pull Request", - numberField: "pull_request_number", - targetLabel: "Target PR:", - currentTargetText: "Current pull request", - includeOperation: true, - }); - async function executePRUpdate(github, context, prNumber, updateData) { - const operation = updateData._operation || "replace"; - const rawBody = updateData._rawBody; - const { _operation, _rawBody, ...apiData } = updateData; - if (rawBody !== undefined && operation !== "replace") { - const { data: currentPR } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - }); - const currentBody = currentPR.body || ""; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "GitHub Agentic Workflow"; - const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - apiData.body = updatePRBody({ - currentBody, - newContent: rawBody, - operation, - workflowName, - runUrl, - runId: context.runId, - }); - core.info(`Will update body (length: ${apiData.body.length})`); - } else if (rawBody !== undefined) { - core.info("Operation: replace (full body replacement)"); - } - const { data: pr } = await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - ...apiData, - }); - return pr; - } - const getSummaryLine = createGetSummaryLine({ - entityPrefix: "PR", - }); - async function main() { - return await runUpdateWorkflow({ - itemType: "update_pull_request", - displayName: "pull request", - displayNamePlural: "pull requests", - numberField: "pull_request_number", - outputNumberKey: "pull_request_number", - outputUrlKey: "pull_request_url", - isValidContext: isPRContext, - getContextNumber: getPRNumber, - supportsStatus: false, - supportsOperation: true, - renderStagedItem, - executeUpdate: executePRUpdate, - getSummaryLine, - }); - } - (async () => { await main(); })(); - - name: Push To Pull Request Branch - id: push_to_pull_request_branch - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'push_to_pull_request_branch')) + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PUSH_IF_NO_CHANGES: "warn" - GH_AW_COMMIT_TITLE_SUFFIX: " [skip-ci]" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Changeset Generator" - GH_AW_ENGINE_ID: "codex" - GH_AW_ENGINE_MODEL: "gpt-5-mini" with: github-token: ${{ steps.app-token.outputs.token }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { updateActivationCommentWithCommit } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - if (agentOutputFile.trim() === "") { - core.info("Agent output content is empty"); - return; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - const target = process.env.GH_AW_PUSH_TARGET || "triggering"; - const ifNoChanges = process.env.GH_AW_PUSH_IF_NO_CHANGES || "warn"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - const message = "No patch file found - cannot push without changes"; - switch (ifNoChanges) { - case "error": - core.setFailed(message); - return; - case "ignore": - return; - case "warn": - default: - core.info(message); - return; - } - } - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchContent.includes("Failed to generate patch")) { - const message = "Patch file contains error message - cannot push without changes"; - core.error("Patch file generation failed - this is an error condition that requires investigation"); - core.error(`Patch file location: /tmp/gh-aw/aw.patch`); - core.error(`Patch file size: ${Buffer.byteLength(patchContent, "utf8")} bytes`); - const previewLength = Math.min(500, patchContent.length); - core.error(`Patch file preview (first ${previewLength} characters):`); - core.error(patchContent.substring(0, previewLength)); - core.setFailed(message); - return; - } - const isEmpty = !patchContent || !patchContent.trim(); - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - core.setFailed(message); - return; - } - core.info("Patch size validation passed"); - } - if (isEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - core.setFailed("No changes to push - failing as configured by if-no-changes: error"); - return; - case "ignore": - break; - case "warn": - default: - core.info(message); - break; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } - core.info(`Target configuration: ${target}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; - } - const pushItem = validatedOutput.items.find( item => item.type === "push_to_pull_request_branch"); - if (!pushItem) { - core.info("No push-to-pull-request-branch item found in agent output"); + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); return; } - core.info("Found push-to-pull-request-branch item"); + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Push to PR Branch", - description: "The following changes would be pushed if staged mode was disabled:", - items: [{ target, commit_message: pushItem.commit_message }], - renderItem: item => { - let content = ""; - content += `**Target:** ${item.target}\n\n`; - if (item.commit_message) { - content += `**Commit Message:** ${item.commit_message}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - content += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - content += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - content += `**Changes:** No changes (empty patch)\n\n`; - } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } - return content; - }, - }); + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; + } + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - if (target !== "*" && target !== "triggering") { - const pullNumber = parseInt(target, 10); - if (isNaN(pullNumber)) { - core.setFailed('Invalid target configuration: must be "triggering", "*", or a valid pull request number'); - return; - } + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - let pullNumber; - if (target === "triggering") { - pullNumber = context.payload?.pull_request?.number || context.payload?.issue?.number; - if (!pullNumber) { - core.setFailed('push-to-pull-request-branch with target "triggering" requires pull request context'); - return; - } - } else if (target === "*") { - if (pushItem.pull_number) { - pullNumber = parseInt(pushItem.pull_number, 10); - } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); } else { - pullNumber = parseInt(target, 10); - } - let branchName; - let prTitle = ""; - let prLabels = []; - try { - const prInfoRes = await exec.getExecOutput(`gh`, [`pr`, `view`, `${pullNumber}`, `--json`, `headRefName,title,labels`, `--jq`, `{headRefName, title, labels: (.labels // [] | map(.name))}`]); - if (prInfoRes.exitCode === 0) { - const prData = JSON.parse(prInfoRes.stdout.trim()); - branchName = prData.headRefName; - prTitle = prData.title || ""; - prLabels = prData.labels || []; - } else { - throw new Error("No PR data found"); + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; + } + } } - } catch (error) { - core.info(`Warning: Could not fetch PR ${pullNumber} details: ${error instanceof Error ? error.message : String(error)}`); - core.setFailed(`Failed to determine branch name for PR ${pullNumber}`); - return; - } - core.info(`Target branch: ${branchName}`); - core.info(`PR title: ${prTitle}`); - core.info(`PR labels: ${prLabels.join(", ")}`); - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !prTitle.startsWith(titlePrefix)) { - core.setFailed(`Pull request title "${prTitle}" does not start with required prefix "${titlePrefix}"`); - return; } - const requiredLabelsStr = process.env.GH_AW_PR_LABELS; - if (requiredLabelsStr) { - const requiredLabels = requiredLabelsStr.split(",").map(label => label.trim()); - const missingLabels = requiredLabels.filter(label => !prLabels.includes(label)); - if (missingLabels.length > 0) { - core.setFailed(`Pull request is missing required labels: ${missingLabels.join(", ")}. Current labels: ${prLabels.join(", ")}`); - return; + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (titlePrefix) { - core.info(`✓ Title prefix validation passed: "${titlePrefix}"`); - } - if (requiredLabelsStr) { - core.info(`✓ Labels validation passed: ${requiredLabelsStr}`); - } - const hasChanges = !isEmpty; - core.info(`Switching to branch: ${branchName}`); - try { - await exec.exec("git fetch origin"); - } catch (fetchError) { - core.setFailed(`Failed to fetch from origin: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`); - return; - } - try { - await exec.exec(`git rev-parse --verify origin/${branchName}`); - } catch (verifyError) { - core.setFailed(`Branch ${branchName} does not exist on origin, can't push to it: ${verifyError instanceof Error ? verifyError.message : String(verifyError)}`); - return; - } - try { - await exec.exec(`git checkout -B ${branchName} origin/${branchName}`); - core.info(`Checked out existing branch from origin: ${branchName}`); - } catch (checkoutError) { - core.setFailed(`Failed to checkout branch ${branchName}: ${checkoutError instanceof Error ? checkoutError.message : String(checkoutError)}`); - return; - } - if (!isEmpty) { - core.info("Applying patch..."); - try { - const commitTitleSuffix = process.env.GH_AW_COMMIT_TITLE_SUFFIX; - if (commitTitleSuffix) { - core.info(`Appending commit title suffix: "${commitTitleSuffix}"`); - let patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchContent = patchContent.replace(/^Subject: (?:\[PATCH\] )?(.*)$/gm, (match, title) => `Subject: [PATCH] ${title}${commitTitleSuffix}`); - fs.writeFileSync("/tmp/gh-aw/aw.patch", patchContent, "utf8"); - core.info(`Patch modified with commit title suffix: "${commitTitleSuffix}"`); - } - const finalPatchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - const patchLines = finalPatchContent.split("\n"); - const previewLineCount = Math.min(100, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - await exec.exec(`git push origin ${branchName}`); - core.info(`Changes committed and pushed to branch: ${branchName}`); - } catch (error) { - core.error(`Failed to apply patch: ${error instanceof Error ? error.message : String(error)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const logResult = await exec.getExecOutput("git", ["log", "--oneline", "-5"]); - core.info("Recent commits (last 5):"); - core.info(logResult.stdout); - const diffResult = await exec.getExecOutput("git", ["diff", "HEAD"]); - core.info("Uncommitted changes:"); - core.info(diffResult.stdout && diffResult.stdout.trim() ? diffResult.stdout : "(no uncommitted changes)"); - const patchDiffResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch diff:"); - core.info(patchDiffResult.stdout); - const patchFullResult = await exec.getExecOutput("git", ["am", "--show-current-patch"]); - core.info("Failed patch (full):"); - core.info(patchFullResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - } else { - core.info("Skipping patch application (empty patch)"); - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - core.setFailed("No changes to apply - failing as configured by if-no-changes: error"); - return; - case "ignore": - break; - case "warn": - default: - core.info(message); - break; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } + await core.summary.addRaw(summaryContent).write(); } - const commitShaRes = await exec.getExecOutput("git", ["rev-parse", "HEAD"]); - if (commitShaRes.exitCode !== 0) throw new Error("Failed to get commit SHA"); - const commitSha = commitShaRes.stdout.trim(); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const repoUrl = context.payload.repository ? context.payload.repository.html_url : `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - const pushUrl = `${repoUrl}/tree/${branchName}`; - const commitUrl = `${repoUrl}/commit/${commitSha}`; - core.setOutput("branch_name", branchName); - core.setOutput("commit_sha", commitSha); - core.setOutput("push_url", pushUrl); - core.setOutput("commit_url", commitUrl); - if (hasChanges) { - await updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl); - } - const summaryTitle = hasChanges ? "Push to Branch" : "Push to Branch (No Changes)"; - const summaryContent = hasChanges - ? ` - ## ${summaryTitle} - - **Branch**: \`${branchName}\` - - **Commit**: [${commitSha.substring(0, 7)}](${commitUrl}) - - **URL**: [${pushUrl}](${pushUrl}) - ` - : ` - ## ${summaryTitle} - - **Branch**: \`${branchName}\` - - **Status**: No changes to apply (noop operation) - - **URL**: [${pushUrl}](${pushUrl}) - `; - await core.summary.addRaw(summaryContent).write(); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); - name: Invalidate GitHub App token if: always() && steps.app-token.outputs.token != '' env: diff --git a/.github/workflows/ci-coach.lock.yml b/.github/workflows/ci-coach.lock.yml index 728a410cd4..1980d74ee5 100644 --- a/.github/workflows/ci-coach.lock.yml +++ b/.github/workflows/ci-coach.lock.yml @@ -7457,8 +7457,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7471,774 +7479,178 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Create Pull Request - id: create_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_PR_TITLE_PREFIX: "[ci-coach] " - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "CI Optimization Coach" - GH_AW_TRACKER_ID: "ci-coach-daily" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { - core.setOutput("pull_request_number", ""); - core.setOutput("pull_request_url", ""); core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); - core.setOutput("branch_name", ""); - core.setOutput("fallback_used", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const workflowId = process.env.GH_AW_WORKFLOW_ID; - if (!workflowId) { - throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); - } - const baseBranch = process.env.GH_AW_BASE_BRANCH; - if (!baseBranch) { - throw new Error("GH_AW_BASE_BRANCH environment variable is required"); - } - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - let outputContent = ""; - if (agentOutputFile.trim() !== "") { - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - } - const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn"; - const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - let patchContent = ""; - let isEmpty = true; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } - if (patchContent.includes("Failed to generate patch")) { - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return; - } - throw new Error(message); - } - core.info("Patch size validation passed"); - } - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to push - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); return; } - core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - if (!title) { - title = "Agent Output"; - } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - const labelsEnv = process.env.GH_AW_PR_LABELS; - const labels = labelsEnv - ? labelsEnv - .split(",") - .map( label => label.trim()) - .filter( label => label) - : []; - const draftEnv = process.env.GH_AW_PR_DRAFT; - const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); - const randomHex = crypto.randomBytes(8).toString("hex"); - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); } else { - branchName = `${branchName}-${randomHex}`; - core.info(`Using branch name from JSONL with added salt: ${branchName}`); - } - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); - core.info(`Fetching latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${baseBranch}`); - core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`); - await exec.exec(`git checkout -b ${branchName}`); - core.info(`Created new branch from base: ${branchName}`); - if (!isEmpty) { - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - try { - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - try { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Changes pushed to branch"); - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - > [!NOTE] - > This was originally intended as a pull request, but the git push operation failed. - > - > **Workflow Run:** [View run details and download patch artifact](${runUrl}) - > - > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above. - To apply the patch locally: - \`\`\`sh - # Download the artifact from the workflow run ${runUrl} - # (Use GitHub MCP tools if gh CLI is not available) - gh run download ${runId} -n aw.patch - # Apply the patch - git am aw.patch - \`\`\` - ${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - core.setOutput("push_failed", "true"); - await core.summary - .addRaw( - ` - ## Push Failure Fallback - - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} - - **Fallback Issue:** [#${issue.number}](${issue.html_url}) - - **Patch Artifact:** Available in workflow run artifacts - - **Note:** Push failed, created issue as fallback - ` - ) - .write(); - return; - } catch (issueError) { - core.setFailed( - `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` - ); - return; } } - } else { - core.info("Skipping patch application (empty patch)"); - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - try { - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Empty branch pushed successfully"); - } catch (pushError) { - core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - return; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - } else { - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to apply - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } + await core.summary.addRaw(summaryContent).write(); } + core.info(`Completed processing ${result.items.length} safe output item(s)`); + } + (async () => { try { - const { data: pullRequest } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }); - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - labels: labels, - }); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } - core.setOutput("pull_request_number", pullRequest.number); - core.setOutput("pull_request_url", pullRequest.html_url); - core.setOutput("branch_name", branchName); - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - await core.summary - .addRaw( - ` - ## Pull Request - - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - - **Branch**: \`${branchName}\` - - **Base Branch**: \`${baseBranch}\` - ` - ) - .write(); - } catch (prError) { - core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); - core.info("Falling back to creating an issue instead"); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). - **Original error:** ${prError instanceof Error ? prError.message : String(prError)} - You can manually create a pull request from the branch if needed.${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - await core.summary - .addRaw( - ` - ## Fallback Issue Created - - **Issue**: [#${issue.number}](${issue.html_url}) - - **Branch**: [\`${branchName}\`](${branchUrl}) - - **Base Branch**: \`${baseBranch}\` - - **Note**: Pull request creation failed, created issue as fallback - ` - ) - .write(); - } catch (issueError) { - core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`); - return; - } + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/ci-doctor.lock.yml b/.github/workflows/ci-doctor.lock.yml index 12593a505c..6f2d164b0a 100644 --- a/.github/workflows/ci-doctor.lock.yml +++ b/.github/workflows/ci-doctor.lock.yml @@ -6968,11 +6968,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6985,1663 +6990,178 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_ISSUE_TITLE_PREFIX: "[CI Failure Doctor] " - GH_AW_WORKFLOW_NAME: "CI Failure Doctor" - GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/ci-doctor.md@ea350161ad5dcc9624cf510f134c6a9e39a6f94d" - GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/ea350161ad5dcc9624cf510f134c6a9e39a6f94d/workflows/ci-doctor.md" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🩺 *Diagnosis provided by [{workflow_name}]({run_url})*\",\"runStarted\":\"🏥 CI Doctor reporting for duty! [{workflow_name}]({run_url}) is examining the patient on this {event_type}...\",\"runSuccess\":\"🩺 Examination complete! [{workflow_name}]({run_url}) has delivered the diagnosis. Prescription issued! 💊\",\"runFailure\":\"🏥 Medical emergency! [{workflow_name}]({run_url}) {status}. Doctor needs assistance...\"}" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; + summaryContent += `**Repository:** ${item.repo}\n\n`; } if (item.body) { - content += `**Body:**\n${item.body}\n\n`; + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; - } - return content; - }, - }); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; - } - } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } - } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } - } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; + summaryContent += "---\n\n"; } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; - } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; } await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); + return; } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); - } - (async () => { - await main(); - })(); - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_CREATED_ISSUE_URL: ${{ steps.create_issue.outputs.issue_url }} - GH_AW_CREATED_ISSUE_NUMBER: ${{ steps.create_issue.outputs.issue_number }} - GH_AW_TEMPORARY_ID_MAP: ${{ steps.create_issue.outputs.temporary_id_map }} - GH_AW_WORKFLOW_NAME: "CI Failure Doctor" - GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/ci-doctor.md@ea350161ad5dcc9624cf510f134c6a9e39a6f94d" - GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/ea350161ad5dcc9624cf510f134c6a9e39a6f94d/workflows/ci-doctor.md" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🩺 *Diagnosis provided by [{workflow_name}]({run_url})*\",\"runStarted\":\"🏥 CI Doctor reporting for duty! [{workflow_name}]({run_url}) is examining the patient on this {event_type}...\",\"runSuccess\":\"🩺 Examination complete! [{workflow_name}]({run_url}) has delivered the diagnosis. Prescription issued! 💊\",\"runFailure\":\"🏥 Medical emergency! [{workflow_name}]({run_url}) {status}. Doctor needs assistance...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); } - page++; } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); - } - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; - } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; - } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); - return; - } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; - } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; - } - } - } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; - } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); - } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); - } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); update_cache_memory: needs: diff --git a/.github/workflows/cli-consistency-checker.lock.yml b/.github/workflows/cli-consistency-checker.lock.yml index c6ae8a9737..5a4cafca7f 100644 --- a/.github/workflows/cli-consistency-checker.lock.yml +++ b/.github/workflows/cli-consistency-checker.lock.yml @@ -6684,9 +6684,16 @@ jobs: issues: write timeout-minutes: 15 outputs: - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6699,944 +6706,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_ISSUE_TITLE_PREFIX: "[cli-consistency] " GH_AW_ISSUE_LABELS: "automation,cli,documentation" - GH_AW_WORKFLOW_NAME: "CLI Consistency Checker" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; + summaryContent += `**Repository:** ${item.repo}\n\n`; } if (item.body) { - content += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } - return content; - }, - }); + summaryContent += "---\n\n"; + } + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } - } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; - } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; } await core.summary.addRaw(summaryContent).write(); } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } (async () => { - await main(); + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } })(); diff --git a/.github/workflows/cli-version-checker.lock.yml b/.github/workflows/cli-version-checker.lock.yml index 4f90338df8..b323934f56 100644 --- a/.github/workflows/cli-version-checker.lock.yml +++ b/.github/workflows/cli-version-checker.lock.yml @@ -6474,9 +6474,16 @@ jobs: issues: write timeout-minutes: 15 outputs: - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6489,945 +6496,178 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_ISSUE_TITLE_PREFIX: "[ca] " GH_AW_ISSUE_LABELS: "automation,dependencies" - GH_AW_WORKFLOW_NAME: "CLI Version Checker" - GH_AW_ENGINE_ID: "claude" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; + summaryContent += `**Repository:** ${item.repo}\n\n`; } if (item.body) { - content += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } - return content; - }, - }); + summaryContent += "---\n\n"; + } + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } - } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; - } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; } await core.summary.addRaw(summaryContent).write(); } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } (async () => { - await main(); + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } })(); update_cache_memory: diff --git a/.github/workflows/cloclo.lock.yml b/.github/workflows/cloclo.lock.yml index 5d7a5d356f..ae53ca1e81 100644 --- a/.github/workflows/cloclo.lock.yml +++ b/.github/workflows/cloclo.lock.yml @@ -7572,10 +7572,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} - create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7588,1764 +7594,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Create Pull Request - id: create_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PR_TITLE_PREFIX: "[cloclo] " GH_AW_PR_LABELS: "automation,cloclo" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "/cloclo" - GH_AW_ENGINE_ID: "claude" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🎤 *Magnifique! Performance by [{workflow_name}]({run_url})*\",\"runStarted\":\"🎵 Comme d'habitude! [{workflow_name}]({run_url}) takes the stage on this {event_type}...\",\"runSuccess\":\"🎤 Bravo! [{workflow_name}]({run_url}) has delivered a stunning performance! Standing ovation! 🌟\",\"runFailure\":\"🎵 Intermission... [{workflow_name}]({run_url}) {status}. The show must go on... eventually!\"}" + GH_AW_PR_TITLE_PREFIX: "[cloclo] " with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { - core.setOutput("pull_request_number", ""); - core.setOutput("pull_request_url", ""); core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); - core.setOutput("branch_name", ""); - core.setOutput("fallback_used", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const workflowId = process.env.GH_AW_WORKFLOW_ID; - if (!workflowId) { - throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); - } - const baseBranch = process.env.GH_AW_BASE_BRANCH; - if (!baseBranch) { - throw new Error("GH_AW_BASE_BRANCH environment variable is required"); - } - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - let outputContent = ""; - if (agentOutputFile.trim() !== "") { - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - } - const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn"; - const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - let patchContent = ""; - let isEmpty = true; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } - if (patchContent.includes("Failed to generate patch")) { - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return; - } - throw new Error(message); - } - core.info("Patch size validation passed"); - } - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to push - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); return; } - core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; - } - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); - return; - } - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - if (!title) { - title = "Agent Output"; - } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - const labelsEnv = process.env.GH_AW_PR_LABELS; - const labels = labelsEnv - ? labelsEnv - .split(",") - .map( label => label.trim()) - .filter( label => label) - : []; - const draftEnv = process.env.GH_AW_PR_DRAFT; - const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); - const randomHex = crypto.randomBytes(8).toString("hex"); - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; - } else { - branchName = `${branchName}-${randomHex}`; - core.info(`Using branch name from JSONL with added salt: ${branchName}`); - } - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); - core.info(`Fetching latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${baseBranch}`); - core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`); - await exec.exec(`git checkout -b ${branchName}`); - core.info(`Created new branch from base: ${branchName}`); - if (!isEmpty) { - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - try { - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - try { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Changes pushed to branch"); - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - > [!NOTE] - > This was originally intended as a pull request, but the git push operation failed. - > - > **Workflow Run:** [View run details and download patch artifact](${runUrl}) - > - > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above. - To apply the patch locally: - \`\`\`sh - # Download the artifact from the workflow run ${runUrl} - # (Use GitHub MCP tools if gh CLI is not available) - gh run download ${runId} -n aw.patch - # Apply the patch - git am aw.patch - \`\`\` - ${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - core.setOutput("push_failed", "true"); - await core.summary - .addRaw( - ` - ## Push Failure Fallback - - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} - - **Fallback Issue:** [#${issue.number}](${issue.html_url}) - - **Patch Artifact:** Available in workflow run artifacts - - **Note:** Push failed, created issue as fallback - ` - ) - .write(); - return; - } catch (issueError) { - core.setFailed( - `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` - ); - return; - } - } - } else { - core.info("Skipping patch application (empty patch)"); - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - try { - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; } - await exec.exec(`git push origin ${branchName}`); - core.info("Empty branch pushed successfully"); - } catch (pushError) { - core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - return; - } - } else { - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to apply - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - try { - const { data: pullRequest } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }); - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - labels: labels, - }); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } - core.setOutput("pull_request_number", pullRequest.number); - core.setOutput("pull_request_url", pullRequest.html_url); - core.setOutput("branch_name", branchName); - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - await core.summary - .addRaw( - ` - ## Pull Request - - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - - **Branch**: \`${branchName}\` - - **Base Branch**: \`${baseBranch}\` - ` - ) - .write(); - } catch (prError) { - core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); - core.info("Falling back to creating an issue instead"); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). - **Original error:** ${prError instanceof Error ? prError.message : String(prError)} - You can manually create a pull request from the branch if needed.${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - await core.summary - .addRaw( - ` - ## Fallback Issue Created - - **Issue**: [#${issue.number}](${issue.html_url}) - - **Branch**: [\`${branchName}\`](${branchUrl}) - - **Base Branch**: \`${baseBranch}\` - - **Note**: Pull request creation failed, created issue as fallback - ` - ) - .write(); - } catch (issueError) { - core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`); - return; - } - } - } - (async () => { await main(); })(); - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_CREATED_PULL_REQUEST_URL: ${{ steps.create_pull_request.outputs.pull_request_url }} - GH_AW_CREATED_PULL_REQUEST_NUMBER: ${{ steps.create_pull_request.outputs.pull_request_number }} - GH_AW_WORKFLOW_NAME: "/cloclo" - GH_AW_ENGINE_ID: "claude" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🎤 *Magnifique! Performance by [{workflow_name}]({run_url})*\",\"runStarted\":\"🎵 Comme d'habitude! [{workflow_name}]({run_url}) takes the stage on this {event_type}...\",\"runSuccess\":\"🎤 Bravo! [{workflow_name}]({run_url}) has delivered a stunning performance! Standing ovation! 🌟\",\"runFailure\":\"🎵 Intermission... [{workflow_name}]({run_url}) {status}. The show must go on... eventually!\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } + summaryContent += "---\n\n"; } } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } - } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; - } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); - return; } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; - } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; - } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); - } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); - } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); update_cache_memory: needs: diff --git a/.github/workflows/close-old-discussions.lock.yml b/.github/workflows/close-old-discussions.lock.yml index 1fc181a4dd..62a4ba97f5 100644 --- a/.github/workflows/close-old-discussions.lock.yml +++ b/.github/workflows/close-old-discussions.lock.yml @@ -6290,6 +6290,17 @@ jobs: contents: read discussions: write timeout-minutes: 15 + outputs: + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6302,494 +6313,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - - name: Close Discussion - id: close_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'close_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Close Outdated Discussions" - GH_AW_ENGINE_ID: "codex" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - async function getDiscussionDetails(github, owner, repo, discussionNumber) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - title - category { - name - } - labels(first: 100) { - nodes { - name - } - } - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - return repository.discussion; - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussion(github, discussionId, reason) { - const mutation = reason - ? ` - mutation($dId: ID!, $reason: DiscussionCloseReason!) { - closeDiscussion(input: { discussionId: $dId, reason: $reason }) { - discussion { - id - url - } - } - }` - : ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId }) { - discussion { - id - url - } - } - }`; - const variables = reason ? { dId: discussionId, reason } : { dId: discussionId }; - const result = await github.graphql(mutation, variables); - return result.closeDiscussion.discussion; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const closeDiscussionItems = result.items.filter( item => item.type === "close_discussion"); - if (closeDiscussionItems.length === 0) { - core.info("No close-discussion items found in agent output"); - return; - } - core.info(`Found ${closeDiscussionItems.length} close-discussion item(s)`); - const requiredLabels = process.env.GH_AW_CLOSE_DISCUSSION_REQUIRED_LABELS ? process.env.GH_AW_CLOSE_DISCUSSION_REQUIRED_LABELS.split(",").map(l => l.trim()) : []; - const requiredTitlePrefix = process.env.GH_AW_CLOSE_DISCUSSION_REQUIRED_TITLE_PREFIX || ""; - const requiredCategory = process.env.GH_AW_CLOSE_DISCUSSION_REQUIRED_CATEGORY || ""; - const target = process.env.GH_AW_CLOSE_DISCUSSION_TARGET || "triggering"; - core.info(`Configuration: requiredLabels=${requiredLabels.join(",")}, requiredTitlePrefix=${requiredTitlePrefix}, requiredCategory=${requiredCategory}, target=${target}`); - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Close Discussions Preview\n\n"; - summaryContent += "The following discussions would be closed if staged mode was disabled:\n\n"; - for (let i = 0; i < closeDiscussionItems.length; i++) { - const item = closeDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - const discussionNumber = item.discussion_number; - if (discussionNumber) { - const repoUrl = getRepositoryUrl(); - const discussionUrl = `${repoUrl}/discussions/${discussionNumber}`; - summaryContent += `**Target Discussion:** [#${discussionNumber}](${discussionUrl})\n\n`; - } else { - summaryContent += `**Target:** Current discussion\n\n`; - } - if (item.reason) { - summaryContent += `**Reason:** ${item.reason}\n\n`; - } - summaryContent += `**Comment:**\n${item.body || "No content provided"}\n\n`; - if (requiredLabels.length > 0) { - summaryContent += `**Required Labels:** ${requiredLabels.join(", ")}\n\n`; - } - if (requiredTitlePrefix) { - summaryContent += `**Required Title Prefix:** ${requiredTitlePrefix}\n\n`; - } - if (requiredCategory) { - summaryContent += `**Required Category:** ${requiredCategory}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion close preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - if (target === "triggering" && !isDiscussionContext) { - core.info('Target is "triggering" but not running in discussion context, skipping discussion close'); - return; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const closedDiscussions = []; - for (let i = 0; i < closeDiscussionItems.length; i++) { - const item = closeDiscussionItems[i]; - core.info(`Processing close-discussion item ${i + 1}/${closeDiscussionItems.length}: bodyLength=${item.body.length}`); - let discussionNumber; - if (target === "*") { - const targetNumber = item.discussion_number; - if (targetNumber) { - discussionNumber = parseInt(targetNumber, 10); - if (isNaN(discussionNumber) || discussionNumber <= 0) { - core.info(`Invalid discussion number specified: ${targetNumber}`); - continue; - } - } else { - core.info(`Target is "*" but no discussion_number specified in close-discussion item`); - continue; - } - } else if (target && target !== "triggering") { - discussionNumber = parseInt(target, 10); - if (isNaN(discussionNumber) || discussionNumber <= 0) { - core.info(`Invalid discussion number in target configuration: ${target}`); - continue; - } - } else { - if (isDiscussionContext) { - discussionNumber = context.payload.discussion?.number; - if (!discussionNumber) { - core.info("Discussion context detected but no discussion found in payload"); - continue; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - } else { - core.info("Not in discussion context and no explicit target specified"); - continue; } } - try { - const discussion = await getDiscussionDetails(github, context.repo.owner, context.repo.repo, discussionNumber); - if (requiredLabels.length > 0) { - const discussionLabels = discussion.labels.nodes.map(l => l.name); - const hasRequiredLabel = requiredLabels.some(required => discussionLabels.includes(required)); - if (!hasRequiredLabel) { - core.info(`Discussion #${discussionNumber} does not have required labels: ${requiredLabels.join(", ")}`); - continue; - } - } - if (requiredTitlePrefix && !discussion.title.startsWith(requiredTitlePrefix)) { - core.info(`Discussion #${discussionNumber} does not have required title prefix: ${requiredTitlePrefix}`); - continue; - } - if (requiredCategory && discussion.category.name !== requiredCategory) { - core.info(`Discussion #${discussionNumber} is not in required category: ${requiredCategory}`); - continue; - } - let body = item.body.trim(); - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - body += getTrackerID("markdown"); - body += generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, undefined, undefined, triggeringDiscussionNumber); - core.info(`Adding comment to discussion #${discussionNumber}`); - core.info(`Comment content length: ${body.length}`); - const comment = await addDiscussionComment(github, discussion.id, body); - core.info("Added discussion comment: " + comment.url); - core.info(`Closing discussion #${discussionNumber} with reason: ${item.reason || "none"}`); - const closedDiscussion = await closeDiscussion(github, discussion.id, item.reason); - core.info("Closed discussion: " + closedDiscussion.url); - closedDiscussions.push({ - number: discussionNumber, - url: discussion.url, - comment_url: comment.url, - }); - if (i === closeDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussionNumber); - core.setOutput("discussion_url", discussion.url); - core.setOutput("comment_url", comment.url); - } - } catch (error) { - core.error(`✗ Failed to close discussion #${discussionNumber}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (closedDiscussions.length > 0) { - let summaryContent = "\n\n## Closed Discussions\n"; - for (const discussion of closedDiscussions) { - summaryContent += `- Discussion #${discussion.number}: [View Discussion](${discussion.url})\n`; - summaryContent += ` - Comment: [View Comment](${discussion.comment_url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully closed ${closedDiscussions.length} discussion(s)`); - return closedDiscussions; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); update_cache_memory: needs: diff --git a/.github/workflows/commit-changes-analyzer.lock.yml b/.github/workflows/commit-changes-analyzer.lock.yml index 631eceed7b..a270548e78 100644 --- a/.github/workflows/commit-changes-analyzer.lock.yml +++ b/.github/workflows/commit-changes-analyzer.lock.yml @@ -6387,8 +6387,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6401,1171 +6409,176 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Commit Changes Analyzer" - GH_AW_ENGINE_ID: "claude" + GH_AW_DISCUSSION_CATEGORY: "dev" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; } } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); diff --git a/.github/workflows/copilot-agent-analysis.lock.yml b/.github/workflows/copilot-agent-analysis.lock.yml index 302287aeba..f124324ba5 100644 --- a/.github/workflows/copilot-agent-analysis.lock.yml +++ b/.github/workflows/copilot-agent-analysis.lock.yml @@ -6784,8 +6784,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6798,1173 +6806,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Copilot Agent PR Analysis" - GH_AW_ENGINE_ID: "claude" - with: + GH_AW_DISCUSSION_CATEGORY: "audits" + GH_AW_DISCUSSION_TITLE_PREFIX: "[copilot-agent-analysis] " +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; - } - } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); } } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); update_cache_memory: needs: diff --git a/.github/workflows/copilot-pr-merged-report.lock.yml b/.github/workflows/copilot-pr-merged-report.lock.yml index 4750f51cf0..3f26cfb1ee 100644 --- a/.github/workflows/copilot-pr-merged-report.lock.yml +++ b/.github/workflows/copilot-pr-merged-report.lock.yml @@ -7969,8 +7969,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7983,1171 +7991,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Daily Copilot PR Merged Report" - GH_AW_ENGINE_ID: "copilot" - with: + GH_AW_DISCUSSION_CATEGORY: "audits" + GH_AW_DISCUSSION_TITLE_PREFIX: "[copilot-pr-merged-report] " +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; } } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); diff --git a/.github/workflows/copilot-pr-nlp-analysis.lock.yml b/.github/workflows/copilot-pr-nlp-analysis.lock.yml index 2c85d2530d..d78fa0e6b7 100644 --- a/.github/workflows/copilot-pr-nlp-analysis.lock.yml +++ b/.github/workflows/copilot-pr-nlp-analysis.lock.yml @@ -7486,8 +7486,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7500,1311 +7508,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Copilot PR Conversation NLP Analysis" - GH_AW_ENGINE_ID: "copilot" - with: + GH_AW_DISCUSSION_CATEGORY: "audit" + GH_AW_DISCUSSION_TITLE_PREFIX: "[nlp-analysis] " +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; } } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); - - name: Upload Assets - id: upload_assets - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'upload_asset')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Copilot PR Conversation NLP Analysis" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const path = require("path"); - const crypto = require("crypto"); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function normalizeBranchName(branchName) { - if (!branchName || typeof branchName !== "string" || branchName.trim() === "") { - return branchName; - } - let normalized = branchName.replace(/[^a-zA-Z0-9\-_/.]+/g, "-"); - normalized = normalized.replace(/-+/g, "-"); - normalized = normalized.replace(/^-+|-+$/g, ""); - if (normalized.length > 128) { - normalized = normalized.substring(0, 128); - } - normalized = normalized.replace(/-+$/, ""); - normalized = normalized.toLowerCase(); - return normalized; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const branchName = process.env.GH_AW_ASSETS_BRANCH; - if (!branchName || typeof branchName !== "string") { - core.setFailed("GH_AW_ASSETS_BRANCH environment variable is required but not set"); - return; - } - const normalizedBranchName = normalizeBranchName(branchName); - core.info(`Using assets branch: ${normalizedBranchName}`); - const result = loadAgentOutput(); - if (!result.success) { - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - const uploadItems = result.items.filter( item => item.type === "upload_assets"); - const uploadAssetItems = result.items.filter( item => item.type === "upload_asset"); - const allUploadItems = [...uploadItems, ...uploadAssetItems]; - if (allUploadItems.length === 0) { - core.info("No upload-asset items found in agent output"); - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - core.info(`Found ${allUploadItems.length} upload-asset item(s)`); - let uploadCount = 0; - let hasChanges = false; + (async () => { try { - try { - await exec.exec(`git rev-parse --verify origin/${normalizedBranchName}`); - await exec.exec(`git checkout -B ${normalizedBranchName} origin/${normalizedBranchName}`); - core.info(`Checked out existing branch from origin: ${normalizedBranchName}`); - } catch (originError) { - if (!normalizedBranchName.startsWith("assets/")) { - core.setFailed( - `Branch '${normalizedBranchName}' does not start with the required 'assets/' prefix. ` + - `Orphaned branches can only be automatically created under the 'assets/' prefix. ` + - `Please create the branch manually first, or use a branch name starting with 'assets/'.` - ); - return; - } - core.info(`Creating new orphaned branch: ${normalizedBranchName}`); - await exec.exec(`git checkout --orphan ${normalizedBranchName}`); - await exec.exec(`git rm -rf .`); - await exec.exec(`git clean -fdx`); - } - for (const asset of uploadAssetItems) { - try { - const { fileName, sha, size, targetFileName } = asset; - if (!fileName || !sha || !targetFileName) { - core.error(`Invalid asset entry missing required fields: ${JSON.stringify(asset)}`); - continue; - } - const assetSourcePath = path.join("/tmp/gh-aw/safeoutputs/assets", fileName); - if (!fs.existsSync(assetSourcePath)) { - core.warning(`Asset file not found: ${assetSourcePath}`); - continue; - } - const fileContent = fs.readFileSync(assetSourcePath); - const computedSha = crypto.createHash("sha256").update(fileContent).digest("hex"); - if (computedSha !== sha) { - core.warning(`SHA mismatch for ${fileName}: expected ${sha}, got ${computedSha}`); - continue; - } - if (fs.existsSync(targetFileName)) { - core.info(`Asset ${targetFileName} already exists, skipping`); - continue; - } - fs.copyFileSync(assetSourcePath, targetFileName); - await exec.exec(`git add "${targetFileName}"`); - uploadCount++; - hasChanges = true; - core.info(`Added asset: ${targetFileName} (${size} bytes)`); - } catch (error) { - core.warning(`Failed to process asset ${asset.fileName}: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (hasChanges) { - const commitMessage = `[skip-ci] Add ${uploadCount} asset(s)`; - await exec.exec(`git`, [`commit`, `-m`, commitMessage]); - if (isStaged) { - core.summary.addRaw("## Staged Asset Publication"); - } else { - await exec.exec(`git push origin ${normalizedBranchName}`); - core.summary.addRaw("## Assets").addRaw(`Successfully uploaded **${uploadCount}** assets to branch \`${normalizedBranchName}\``).addRaw(""); - core.info(`Successfully uploaded ${uploadCount} assets to branch ${normalizedBranchName}`); - } - for (const asset of uploadAssetItems) { - if (asset.fileName && asset.sha && asset.size && asset.url) { - core.summary.addRaw(`- [\`${asset.fileName}\`](${asset.url}) → \`${asset.targetFileName}\` (${asset.size} bytes)`); - } - } - core.summary.write(); - } else { - core.info("No new assets to upload"); - } + await main(); } catch (error) { - core.setFailed(`Failed to upload assets: ${error instanceof Error ? error.message : String(error)}`); - return; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - core.setOutput("upload_count", uploadCount.toString()); - core.setOutput("branch_name", normalizedBranchName); - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/copilot-pr-prompt-analysis.lock.yml b/.github/workflows/copilot-pr-prompt-analysis.lock.yml index e220f3341f..7f012314c4 100644 --- a/.github/workflows/copilot-pr-prompt-analysis.lock.yml +++ b/.github/workflows/copilot-pr-prompt-analysis.lock.yml @@ -6971,8 +6971,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6985,1173 +6993,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Copilot PR Prompt Pattern Analysis" - GH_AW_ENGINE_ID: "copilot" - with: + GH_AW_DISCUSSION_CATEGORY: "audits" + GH_AW_DISCUSSION_TITLE_PREFIX: "[prompt-analysis] " +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; - } - } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); } } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); update_cache_memory: needs: diff --git a/.github/workflows/copilot-session-insights.lock.yml b/.github/workflows/copilot-session-insights.lock.yml index a2d4a40f80..8f0a463954 100644 --- a/.github/workflows/copilot-session-insights.lock.yml +++ b/.github/workflows/copilot-session-insights.lock.yml @@ -7523,8 +7523,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7537,1311 +7545,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Copilot Session Insights" - GH_AW_ENGINE_ID: "claude" - with: + GH_AW_DISCUSSION_CATEGORY: "audits" + GH_AW_DISCUSSION_TITLE_PREFIX: "[copilot-session-insights] " +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; } } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); - - name: Upload Assets - id: upload_assets - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'upload_asset')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Copilot Session Insights" - GH_AW_ENGINE_ID: "claude" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const path = require("path"); - const crypto = require("crypto"); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function normalizeBranchName(branchName) { - if (!branchName || typeof branchName !== "string" || branchName.trim() === "") { - return branchName; - } - let normalized = branchName.replace(/[^a-zA-Z0-9\-_/.]+/g, "-"); - normalized = normalized.replace(/-+/g, "-"); - normalized = normalized.replace(/^-+|-+$/g, ""); - if (normalized.length > 128) { - normalized = normalized.substring(0, 128); - } - normalized = normalized.replace(/-+$/, ""); - normalized = normalized.toLowerCase(); - return normalized; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const branchName = process.env.GH_AW_ASSETS_BRANCH; - if (!branchName || typeof branchName !== "string") { - core.setFailed("GH_AW_ASSETS_BRANCH environment variable is required but not set"); - return; - } - const normalizedBranchName = normalizeBranchName(branchName); - core.info(`Using assets branch: ${normalizedBranchName}`); - const result = loadAgentOutput(); - if (!result.success) { - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - const uploadItems = result.items.filter( item => item.type === "upload_assets"); - const uploadAssetItems = result.items.filter( item => item.type === "upload_asset"); - const allUploadItems = [...uploadItems, ...uploadAssetItems]; - if (allUploadItems.length === 0) { - core.info("No upload-asset items found in agent output"); - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - core.info(`Found ${allUploadItems.length} upload-asset item(s)`); - let uploadCount = 0; - let hasChanges = false; + (async () => { try { - try { - await exec.exec(`git rev-parse --verify origin/${normalizedBranchName}`); - await exec.exec(`git checkout -B ${normalizedBranchName} origin/${normalizedBranchName}`); - core.info(`Checked out existing branch from origin: ${normalizedBranchName}`); - } catch (originError) { - if (!normalizedBranchName.startsWith("assets/")) { - core.setFailed( - `Branch '${normalizedBranchName}' does not start with the required 'assets/' prefix. ` + - `Orphaned branches can only be automatically created under the 'assets/' prefix. ` + - `Please create the branch manually first, or use a branch name starting with 'assets/'.` - ); - return; - } - core.info(`Creating new orphaned branch: ${normalizedBranchName}`); - await exec.exec(`git checkout --orphan ${normalizedBranchName}`); - await exec.exec(`git rm -rf .`); - await exec.exec(`git clean -fdx`); - } - for (const asset of uploadAssetItems) { - try { - const { fileName, sha, size, targetFileName } = asset; - if (!fileName || !sha || !targetFileName) { - core.error(`Invalid asset entry missing required fields: ${JSON.stringify(asset)}`); - continue; - } - const assetSourcePath = path.join("/tmp/gh-aw/safeoutputs/assets", fileName); - if (!fs.existsSync(assetSourcePath)) { - core.warning(`Asset file not found: ${assetSourcePath}`); - continue; - } - const fileContent = fs.readFileSync(assetSourcePath); - const computedSha = crypto.createHash("sha256").update(fileContent).digest("hex"); - if (computedSha !== sha) { - core.warning(`SHA mismatch for ${fileName}: expected ${sha}, got ${computedSha}`); - continue; - } - if (fs.existsSync(targetFileName)) { - core.info(`Asset ${targetFileName} already exists, skipping`); - continue; - } - fs.copyFileSync(assetSourcePath, targetFileName); - await exec.exec(`git add "${targetFileName}"`); - uploadCount++; - hasChanges = true; - core.info(`Added asset: ${targetFileName} (${size} bytes)`); - } catch (error) { - core.warning(`Failed to process asset ${asset.fileName}: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (hasChanges) { - const commitMessage = `[skip-ci] Add ${uploadCount} asset(s)`; - await exec.exec(`git`, [`commit`, `-m`, commitMessage]); - if (isStaged) { - core.summary.addRaw("## Staged Asset Publication"); - } else { - await exec.exec(`git push origin ${normalizedBranchName}`); - core.summary.addRaw("## Assets").addRaw(`Successfully uploaded **${uploadCount}** assets to branch \`${normalizedBranchName}\``).addRaw(""); - core.info(`Successfully uploaded ${uploadCount} assets to branch ${normalizedBranchName}`); - } - for (const asset of uploadAssetItems) { - if (asset.fileName && asset.sha && asset.size && asset.url) { - core.summary.addRaw(`- [\`${asset.fileName}\`](${asset.url}) → \`${asset.targetFileName}\` (${asset.size} bytes)`); - } - } - core.summary.write(); - } else { - core.info("No new assets to upload"); - } + await main(); } catch (error) { - core.setFailed(`Failed to upload assets: ${error instanceof Error ? error.message : String(error)}`); - return; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - core.setOutput("upload_count", uploadCount.toString()); - core.setOutput("branch_name", normalizedBranchName); - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/craft.lock.yml b/.github/workflows/craft.lock.yml index d1932eb617..73e58a3754 100644 --- a/.github/workflows/craft.lock.yml +++ b/.github/workflows/craft.lock.yml @@ -7737,9 +7737,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} - push_to_pull_request_branch_commit_url: ${{ steps.push_to_pull_request_branch.outputs.commit_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7752,1533 +7759,175 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Workflow Craft Agent" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e ⚒️ *Crafted with care by [{workflow_name}]({run_url})*\",\"runStarted\":\"🛠️ Master Crafter at work! [{workflow_name}]({run_url}) is forging a new workflow on this {event_type}...\",\"runSuccess\":\"⚒️ Masterpiece complete! [{workflow_name}]({run_url}) has crafted your workflow. May it serve you well! 🎖️\",\"runFailure\":\"🛠️ Forge cooling down! [{workflow_name}]({run_url}) {status}. The anvil awaits another attempt...\"}" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } - } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } - } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); - return; - } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; - } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } + summaryContent += "---\n\n"; } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; - } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); - } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); - } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); - } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; - } - (async () => { await main(); })(); - - name: Push To Pull Request Branch - id: push_to_pull_request_branch - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'push_to_pull_request_branch')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PUSH_IF_NO_CHANGES: "warn" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Workflow Craft Agent" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e ⚒️ *Crafted with care by [{workflow_name}]({run_url})*\",\"runStarted\":\"🛠️ Master Crafter at work! [{workflow_name}]({run_url}) is forging a new workflow on this {event_type}...\",\"runSuccess\":\"⚒️ Masterpiece complete! [{workflow_name}]({run_url}) has crafted your workflow. May it serve you well! 🎖️\",\"runFailure\":\"🛠️ Forge cooling down! [{workflow_name}]({run_url}) {status}. The anvil awaits another attempt...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { updateActivationCommentWithCommit } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - if (agentOutputFile.trim() === "") { - core.info("Agent output content is empty"); + core.info("📝 Safe outputs preview written to step summary"); return; } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - const target = process.env.GH_AW_PUSH_TARGET || "triggering"; - const ifNoChanges = process.env.GH_AW_PUSH_IF_NO_CHANGES || "warn"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - const message = "No patch file found - cannot push without changes"; - switch (ifNoChanges) { - case "error": - core.setFailed(message); - return; - case "ignore": - return; - case "warn": - default: - core.info(message); - return; - } - } - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchContent.includes("Failed to generate patch")) { - const message = "Patch file contains error message - cannot push without changes"; - core.error("Patch file generation failed - this is an error condition that requires investigation"); - core.error(`Patch file location: /tmp/gh-aw/aw.patch`); - core.error(`Patch file size: ${Buffer.byteLength(patchContent, "utf8")} bytes`); - const previewLength = Math.min(500, patchContent.length); - core.error(`Patch file preview (first ${previewLength} characters):`); - core.error(patchContent.substring(0, previewLength)); - core.setFailed(message); - return; - } - const isEmpty = !patchContent || !patchContent.trim(); - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - core.setFailed(message); - return; - } - core.info("Patch size validation passed"); - } - if (isEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - core.setFailed("No changes to push - failing as configured by if-no-changes: error"); - return; - case "ignore": - break; - case "warn": - default: - core.info(message); - break; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } - core.info(`Target configuration: ${target}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; - } - const pushItem = validatedOutput.items.find( item => item.type === "push_to_pull_request_branch"); - if (!pushItem) { - core.info("No push-to-pull-request-branch item found in agent output"); - return; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - core.info("Found push-to-pull-request-branch item"); - if (isStaged) { - await generateStagedPreview({ - title: "Push to PR Branch", - description: "The following changes would be pushed if staged mode was disabled:", - items: [{ target, commit_message: pushItem.commit_message }], - renderItem: item => { - let content = ""; - content += `**Target:** ${item.target}\n\n`; - if (item.commit_message) { - content += `**Commit Message:** ${item.commit_message}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - content += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - content += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - content += `**Changes:** No changes (empty patch)\n\n`; - } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - return content; - }, - }); - return; - } - if (target !== "*" && target !== "triggering") { - const pullNumber = parseInt(target, 10); - if (isNaN(pullNumber)) { - core.setFailed('Invalid target configuration: must be "triggering", "*", or a valid pull request number'); - return; + } } } - let pullNumber; - if (target === "triggering") { - pullNumber = context.payload?.pull_request?.number || context.payload?.issue?.number; - if (!pullNumber) { - core.setFailed('push-to-pull-request-branch with target "triggering" requires pull request context'); - return; + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } - } else if (target === "*") { - if (pushItem.pull_number) { - pullNumber = parseInt(pushItem.pull_number, 10); - } - } else { - pullNumber = parseInt(target, 10); } - let branchName; - let prTitle = ""; - let prLabels = []; - try { - const prInfoRes = await exec.getExecOutput(`gh`, [`pr`, `view`, `${pullNumber}`, `--json`, `headRefName,title,labels`, `--jq`, `{headRefName, title, labels: (.labels // [] | map(.name))}`]); - if (prInfoRes.exitCode === 0) { - const prData = JSON.parse(prInfoRes.stdout.trim()); - branchName = prData.headRefName; - prTitle = prData.title || ""; - prLabels = prData.labels || []; - } else { - throw new Error("No PR data found"); + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - } catch (error) { - core.info(`Warning: Could not fetch PR ${pullNumber} details: ${error instanceof Error ? error.message : String(error)}`); - core.setFailed(`Failed to determine branch name for PR ${pullNumber}`); - return; - } - core.info(`Target branch: ${branchName}`); - core.info(`PR title: ${prTitle}`); - core.info(`PR labels: ${prLabels.join(", ")}`); - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !prTitle.startsWith(titlePrefix)) { - core.setFailed(`Pull request title "${prTitle}" does not start with required prefix "${titlePrefix}"`); - return; - } - const requiredLabelsStr = process.env.GH_AW_PR_LABELS; - if (requiredLabelsStr) { - const requiredLabels = requiredLabelsStr.split(",").map(label => label.trim()); - const missingLabels = requiredLabels.filter(label => !prLabels.includes(label)); - if (missingLabels.length > 0) { - core.setFailed(`Pull request is missing required labels: ${missingLabels.join(", ")}. Current labels: ${prLabels.join(", ")}`); - return; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } + await core.summary.addRaw(summaryContent).write(); } - if (titlePrefix) { - core.info(`✓ Title prefix validation passed: "${titlePrefix}"`); - } - if (requiredLabelsStr) { - core.info(`✓ Labels validation passed: ${requiredLabelsStr}`); - } - const hasChanges = !isEmpty; - core.info(`Switching to branch: ${branchName}`); - try { - await exec.exec("git fetch origin"); - } catch (fetchError) { - core.setFailed(`Failed to fetch from origin: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`); - return; - } - try { - await exec.exec(`git rev-parse --verify origin/${branchName}`); - } catch (verifyError) { - core.setFailed(`Branch ${branchName} does not exist on origin, can't push to it: ${verifyError instanceof Error ? verifyError.message : String(verifyError)}`); - return; - } + core.info(`Completed processing ${result.items.length} safe output item(s)`); + } + (async () => { try { - await exec.exec(`git checkout -B ${branchName} origin/${branchName}`); - core.info(`Checked out existing branch from origin: ${branchName}`); - } catch (checkoutError) { - core.setFailed(`Failed to checkout branch ${branchName}: ${checkoutError instanceof Error ? checkoutError.message : String(checkoutError)}`); - return; - } - if (!isEmpty) { - core.info("Applying patch..."); - try { - const commitTitleSuffix = process.env.GH_AW_COMMIT_TITLE_SUFFIX; - if (commitTitleSuffix) { - core.info(`Appending commit title suffix: "${commitTitleSuffix}"`); - let patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchContent = patchContent.replace(/^Subject: (?:\[PATCH\] )?(.*)$/gm, (match, title) => `Subject: [PATCH] ${title}${commitTitleSuffix}`); - fs.writeFileSync("/tmp/gh-aw/aw.patch", patchContent, "utf8"); - core.info(`Patch modified with commit title suffix: "${commitTitleSuffix}"`); - } - const finalPatchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - const patchLines = finalPatchContent.split("\n"); - const previewLineCount = Math.min(100, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - await exec.exec(`git push origin ${branchName}`); - core.info(`Changes committed and pushed to branch: ${branchName}`); - } catch (error) { - core.error(`Failed to apply patch: ${error instanceof Error ? error.message : String(error)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const logResult = await exec.getExecOutput("git", ["log", "--oneline", "-5"]); - core.info("Recent commits (last 5):"); - core.info(logResult.stdout); - const diffResult = await exec.getExecOutput("git", ["diff", "HEAD"]); - core.info("Uncommitted changes:"); - core.info(diffResult.stdout && diffResult.stdout.trim() ? diffResult.stdout : "(no uncommitted changes)"); - const patchDiffResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch diff:"); - core.info(patchDiffResult.stdout); - const patchFullResult = await exec.getExecOutput("git", ["am", "--show-current-patch"]); - core.info("Failed patch (full):"); - core.info(patchFullResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - } else { - core.info("Skipping patch application (empty patch)"); - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - core.setFailed("No changes to apply - failing as configured by if-no-changes: error"); - return; - case "ignore": - break; - case "warn": - default: - core.info(message); - break; - } + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - const commitShaRes = await exec.getExecOutput("git", ["rev-parse", "HEAD"]); - if (commitShaRes.exitCode !== 0) throw new Error("Failed to get commit SHA"); - const commitSha = commitShaRes.stdout.trim(); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const repoUrl = context.payload.repository ? context.payload.repository.html_url : `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - const pushUrl = `${repoUrl}/tree/${branchName}`; - const commitUrl = `${repoUrl}/commit/${commitSha}`; - core.setOutput("branch_name", branchName); - core.setOutput("commit_sha", commitSha); - core.setOutput("push_url", pushUrl); - core.setOutput("commit_url", commitUrl); - if (hasChanges) { - await updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl); - } - const summaryTitle = hasChanges ? "Push to Branch" : "Push to Branch (No Changes)"; - const summaryContent = hasChanges - ? ` - ## ${summaryTitle} - - **Branch**: \`${branchName}\` - - **Commit**: [${commitSha.substring(0, 7)}](${commitUrl}) - - **URL**: [${pushUrl}](${pushUrl}) - ` - : ` - ## ${summaryTitle} - - **Branch**: \`${branchName}\` - - **Status**: No changes to apply (noop operation) - - **URL**: [${pushUrl}](${pushUrl}) - `; - await core.summary.addRaw(summaryContent).write(); - } - (async () => { await main(); })(); + })(); diff --git a/.github/workflows/daily-assign-issue-to-user.lock.yml b/.github/workflows/daily-assign-issue-to-user.lock.yml index d37136c5c9..c5d8c73c5a 100644 --- a/.github/workflows/daily-assign-issue-to-user.lock.yml +++ b/.github/workflows/daily-assign-issue-to-user.lock.yml @@ -6479,9 +6479,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} - assign_to_user_assigned: ${{ steps.assign_to_user.outputs.assigned }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6494,1808 +6501,175 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/safe_output_helpers.cjs << 'EOF_80a143d8' - // @ts-check - /// - - /** - * Shared helper functions for safe-output scripts - * Provides common validation and target resolution logic - */ - - /** - * Parse a comma-separated list of allowed items from environment variable - * @param {string|undefined} envValue - Environment variable value - * @returns {string[]|undefined} Array of allowed items, or undefined if no restrictions - */ - function parseAllowedItems(envValue) { - const trimmed = envValue?.trim(); - if (!trimmed) { - return undefined; - } - return trimmed - .split(",") - .map(item => item.trim()) - .filter(item => item); - } - - /** - * Parse and validate max count from environment variable - * @param {string|undefined} envValue - Environment variable value - * @param {number} defaultValue - Default value if not specified - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function parseMaxCount(envValue, defaultValue = 3) { - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - /** - * Resolve the target number (issue/PR) based on configuration and context - * @param {Object} params - Resolution parameters - * @param {string} params.targetConfig - Target configuration ("triggering", "*", or explicit number) - * @param {any} params.item - Safe output item with optional item_number or pull_request_number - * @param {any} params.context - GitHub Actions context - * @param {string} params.itemType - Type of item being processed (for error messages) - * @param {boolean} params.supportsPR - Whether this safe output supports PR context - * @returns {{success: true, number: number, contextType: string} | {success: false, error: string, shouldFail: boolean}} Resolution result - */ - function resolveTarget(params) { - const { targetConfig, item, context, itemType, supportsPR = false } = params; - - // Check context type - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - - // Default target is "triggering" - const target = targetConfig || "triggering"; - - // Validate context for triggering mode - if (target === "triggering") { - if (supportsPR) { - if (!isIssueContext && !isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in issue or pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } else { - if (!isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } - } - - // Resolve target number - let itemNumber; - let contextType; - - if (target === "*") { - // Use item_number, issue_number, or pull_request_number from item - const numberField = supportsPR ? item.item_number || item.issue_number || item.pull_request_number : item.pull_request_number; - - if (numberField) { - itemNumber = typeof numberField === "number" ? numberField : parseInt(String(numberField), 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "item_number/issue_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, - shouldFail: true, - }; - } - contextType = supportsPR && (item.item_number || item.issue_number) ? "issue" : "pull request"; - } else { - return { - success: false, - error: `Target is "*" but no ${supportsPR ? "item_number/issue_number" : "pull_request_number"} specified in ${itemType} item`, - shouldFail: true, - }; - } - } else if (target !== "triggering") { - // Explicit number - itemNumber = parseInt(target, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "issue" : "pull request"} number in target configuration: ${target}`, - shouldFail: true, - }; - } - contextType = supportsPR ? "issue" : "pull request"; - } else { - // Use triggering context - if (isIssueContext) { - if (context.payload.issue) { - itemNumber = context.payload.issue.number; - contextType = "issue"; - } else { - return { - success: false, - error: "Issue context detected but no issue found in payload", - shouldFail: true, - }; - } - } else if (isPRContext) { - if (context.payload.pull_request) { - itemNumber = context.payload.pull_request.number; - contextType = "pull request"; - } else { - return { - success: false, - error: "Pull request context detected but no pull request found in payload", - shouldFail: true, - }; - } - } - } - - if (!itemNumber) { - return { - success: false, - error: `Could not determine ${supportsPR ? "issue or pull request" : "pull request"} number`, - shouldFail: true, - }; - } - - return { - success: true, - number: itemNumber, - contextType: contextType || (supportsPR ? "issue" : "pull request"), - }; - } - - module.exports = { - parseAllowedItems, - parseMaxCount, - resolveTarget, - }; - - EOF_80a143d8 - cat > /tmp/gh-aw/scripts/safe_output_processor.cjs << 'EOF_8f3864e2' - // @ts-check - /// - - /** - * Shared processor for safe-output scripts - * Provides common pipeline: load agent output, handle staged mode, parse config, resolve target - */ - - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { parseAllowedItems, resolveTarget } = require('/tmp/gh-aw/scripts/safe_output_helpers.cjs'); - const { getSafeOutputConfig, validateMaxCount } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs'); - - /** - * @typedef {Object} ProcessorConfig - * @property {string} itemType - The type field value to match in agent output (e.g., "add_labels") - * @property {string} configKey - The key to use when reading from config.json (e.g., "add_labels") - * @property {string} displayName - Human-readable name for logging (e.g., "Add Labels") - * @property {string} itemTypeName - Name used in error messages (e.g., "label addition") - * @property {boolean} [supportsPR] - When true, allows both issue AND PR contexts; when false, only PR context (default: false) - * @property {boolean} [supportsIssue] - When true, passes supportsPR=true to resolveTarget to enable both contexts (default: false) - * @property {boolean} [findMultiple] - Whether to find multiple items instead of just one (default: false) - * @property {Object} envVars - Environment variable names - * @property {string} [envVars.allowed] - Env var for allowed items list - * @property {string} [envVars.maxCount] - Env var for max count - * @property {string} [envVars.target] - Env var for target configuration - */ - - /** - * @typedef {Object} ProcessorResult - * @property {boolean} success - Whether processing should continue - * @property {any} [item] - The found item (when findMultiple is false) - * @property {any[]} [items] - The found items (when findMultiple is true) - * @property {Object} [config] - Parsed configuration - * @property {string[]|undefined} [config.allowed] - Allowed items list - * @property {number} [config.maxCount] - Maximum count - * @property {string} [config.target] - Target configuration - * @property {Object} [targetResult] - Result from resolveTarget (when findMultiple is false) - * @property {number} [targetResult.number] - Target issue/PR number - * @property {string} [targetResult.contextType] - Type of context (issue or pull request) - * @property {string} [reason] - Reason why processing should not continue - */ - - /** - * Process the initial steps common to safe-output scripts: - * 1. Load agent output - * 2. Find matching item(s) - * 3. Handle staged mode - * 4. Parse configuration - * 5. Resolve target (for single-item processors) - * - * @param {ProcessorConfig} config - Processor configuration - * @param {Object} stagedPreviewOptions - Options for staged preview - * @param {string} stagedPreviewOptions.title - Title for staged preview - * @param {string} stagedPreviewOptions.description - Description for staged preview - * @param {(item: any, index: number) => string} stagedPreviewOptions.renderItem - Function to render item in preview - * @returns {Promise} Processing result - */ - async function processSafeOutput(config, stagedPreviewOptions) { - const { itemType, configKey, displayName, itemTypeName, supportsPR = false, supportsIssue = false, findMultiple = false, envVars } = config; - - // Step 1: Load agent output - const result = loadAgentOutput(); - if (!result.success) { - return { success: false, reason: "Agent output not available" }; - } - - // Step 2: Find matching item(s) - let items; - if (findMultiple) { - items = result.items.filter(item => item.type === itemType); - if (items.length === 0) { - core.info(`No ${itemType} items found in agent output`); - return { success: false, reason: `No ${itemType} items found` }; - } - core.info(`Found ${items.length} ${itemType} item(s)`); - } else { - const item = result.items.find(item => item.type === itemType); - if (!item) { - core.warning(`No ${itemType.replace(/_/g, "-")} item found in agent output`); - return { success: false, reason: `No ${itemType} item found` }; - } - items = [item]; - // Log item details based on common fields - const itemDetails = getItemDetails(item); - if (itemDetails) { - core.info(`Found ${itemType.replace(/_/g, "-")} item with ${itemDetails}`); - } - } - - // Step 3: Handle staged mode - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - await generateStagedPreview({ - title: stagedPreviewOptions.title, - description: stagedPreviewOptions.description, - items: items, - renderItem: stagedPreviewOptions.renderItem, - }); - return { success: false, reason: "Staged mode - preview generated" }; - } - - // Step 4: Parse configuration - const safeOutputConfig = getSafeOutputConfig(configKey); - - // Parse allowed items (from env or config) - const allowedEnvValue = envVars.allowed ? process.env[envVars.allowed] : undefined; - const allowed = parseAllowedItems(allowedEnvValue) || safeOutputConfig.allowed; - if (allowed) { - core.info(`Allowed ${itemTypeName}s: ${JSON.stringify(allowed)}`); - } else { - core.info(`No ${itemTypeName} restrictions - any ${itemTypeName}s are allowed`); - } - - // Parse max count (env takes priority, then config) - const maxCountEnvValue = envVars.maxCount ? process.env[envVars.maxCount] : undefined; - const maxCountResult = validateMaxCount(maxCountEnvValue, safeOutputConfig.max); - if (!maxCountResult.valid) { - core.setFailed(maxCountResult.error); - return { success: false, reason: "Invalid max count configuration" }; - } - const maxCount = maxCountResult.value; - core.info(`Max count: ${maxCount}`); - - // Get target configuration - const target = envVars.target ? process.env[envVars.target] || "triggering" : "triggering"; - core.info(`${displayName} target configuration: ${target}`); - - // For multiple items, return early without target resolution - if (findMultiple) { - return { - success: true, - items: items, - config: { - allowed, - maxCount, - target, - }, - }; - } - - // Step 5: Resolve target (for single-item processors) - const item = items[0]; - const targetResult = resolveTarget({ - targetConfig: target, - item: item, - context, - itemType: itemTypeName, - // supportsPR in resolveTarget: true=both issue and PR contexts, false=PR-only - // If supportsIssue is true, we pass supportsPR=true to enable both contexts - supportsPR: supportsPR || supportsIssue, - }); - - if (!targetResult.success) { - if (targetResult.shouldFail) { - core.setFailed(targetResult.error); - } else { - core.info(targetResult.error); - } - return { success: false, reason: targetResult.error }; - } - - return { - success: true, - item: item, - config: { - allowed, - maxCount, - target, - }, - targetResult: { - number: targetResult.number, - contextType: targetResult.contextType, - }, - }; - } - - /** - * Get a description of item details for logging - * @param {any} item - The safe output item - * @returns {string|null} Description string or null - */ - function getItemDetails(item) { - if (item.labels && Array.isArray(item.labels)) { - return `${item.labels.length} labels`; - } - if (item.reviewers && Array.isArray(item.reviewers)) { - return `${item.reviewers.length} reviewers`; - } - return null; - } - - /** - * Sanitize and deduplicate an array of string items - * @param {any[]} items - Raw items array - * @returns {string[]} Sanitized and deduplicated array - */ - function sanitizeItems(items) { - return items - .filter(item => item != null && item !== false && item !== 0) - .map(item => String(item).trim()) - .filter(item => item) - .filter((item, index, arr) => arr.indexOf(item) === index); - } - - /** - * Filter items by allowed list - * @param {string[]} items - Items to filter - * @param {string[]|undefined} allowed - Allowed items list (undefined means all allowed) - * @returns {string[]} Filtered items - */ - function filterByAllowed(items, allowed) { - if (!allowed || allowed.length === 0) { - return items; - } - return items.filter(item => allowed.includes(item)); - } - - /** - * Limit items to max count - * @param {string[]} items - Items to limit - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Limited items - */ - function limitToMaxCount(items, maxCount) { - if (items.length > maxCount) { - core.info(`Too many items (${items.length}), limiting to ${maxCount}`); - return items.slice(0, maxCount); - } - return items; - } - - /** - * Process items through the standard pipeline: filter by allowed, sanitize, dedupe, limit - * @param {any[]} rawItems - Raw items array from agent output - * @param {string[]|undefined} allowed - Allowed items list - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Processed items - */ - function processItems(rawItems, allowed, maxCount) { - // Filter by allowed list first - const filtered = filterByAllowed(rawItems, allowed); - - // Sanitize and deduplicate - const sanitized = sanitizeItems(filtered); - - // Limit to max count - return limitToMaxCount(sanitized, maxCount); - } - - module.exports = { - processSafeOutput, - sanitizeItems, - filterByAllowed, - limitToMaxCount, - processItems, - }; - - EOF_8f3864e2 - cat > /tmp/gh-aw/scripts/safe_output_validator.cjs << 'EOF_437e6b4f' - // @ts-check - /// - - const fs = require("fs"); - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - - /** - * Load and parse the safe outputs configuration from config.json - * @returns {object} The parsed configuration object - */ - function loadSafeOutputsConfig() { - const configPath = "/tmp/gh-aw/safeoutputs/config.json"; - try { - if (!fs.existsSync(configPath)) { - core.warning(`Config file not found at ${configPath}, using defaults`); - return {}; - } - const configContent = fs.readFileSync(configPath, "utf8"); - return JSON.parse(configContent); - } catch (error) { - core.warning(`Failed to load config: ${error instanceof Error ? error.message : String(error)}`); - return {}; - } - } - - /** - * Get configuration for a specific safe output type - * @param {string} outputType - The type of safe output (e.g., "add_labels", "update_issue") - * @returns {{max?: number, target?: string, allowed?: string[]}} The configuration for this output type - */ - function getSafeOutputConfig(outputType) { - const config = loadSafeOutputsConfig(); - return config[outputType] || {}; - } - - /** - * Validate and sanitize a title string - * @param {any} title - The title to validate - * @param {string} fieldName - The name of the field for error messages (default: "title") - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateTitle(title, fieldName = "title") { - if (title === undefined || title === null) { - return { valid: false, error: `${fieldName} is required` }; - } - - if (typeof title !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - const trimmed = title.trim(); - if (trimmed.length === 0) { - return { valid: false, error: `${fieldName} cannot be empty` }; - } - - return { valid: true, value: trimmed }; - } - - /** - * Validate and sanitize a body/content string - * @param {any} body - The body to validate - * @param {string} fieldName - The name of the field for error messages (default: "body") - * @param {boolean} required - Whether the body is required (default: false) - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateBody(body, fieldName = "body", required = false) { - if (body === undefined || body === null) { - if (required) { - return { valid: false, error: `${fieldName} is required` }; - } - return { valid: true, value: "" }; - } - - if (typeof body !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - return { valid: true, value: body }; - } - - /** - * Validate and sanitize an array of labels - * @param {any} labels - The labels to validate - * @param {string[]|undefined} allowedLabels - Optional list of allowed labels - * @param {number} maxCount - Maximum number of labels allowed - * @returns {{valid: boolean, value?: string[], error?: string}} Validation result - */ - function validateLabels(labels, allowedLabels = undefined, maxCount = 3) { - if (!labels || !Array.isArray(labels)) { - return { valid: false, error: "labels must be an array" }; - } - - // Check for removal attempts (labels starting with '-') - for (const label of labels) { - if (label && typeof label === "string" && label.startsWith("-")) { - return { valid: false, error: `Label removal is not permitted. Found line starting with '-': ${label}` }; - } - } - - // Filter labels based on allowed list if provided - let validLabels = labels; - if (allowedLabels && allowedLabels.length > 0) { - validLabels = labels.filter(label => allowedLabels.includes(label)); - } - - // Sanitize and deduplicate labels - const uniqueLabels = validLabels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - - // Apply max count limit - if (uniqueLabels.length > maxCount) { - core.info(`Too many labels (${uniqueLabels.length}), limiting to ${maxCount}`); - return { valid: true, value: uniqueLabels.slice(0, maxCount) }; - } - - if (uniqueLabels.length === 0) { - return { valid: false, error: "No valid labels found after sanitization" }; - } - - return { valid: true, value: uniqueLabels }; - } - - /** - * Validate max count from environment variable with config fallback - * @param {string|undefined} envValue - Environment variable value - * @param {number|undefined} configDefault - Default from config.json - * @param {number} [fallbackDefault] - Fallback default for testing (optional, defaults to 1) - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function validateMaxCount(envValue, configDefault, fallbackDefault = 1) { - // Priority: env var > config.json > fallback default - // In production, config.json should always have the default - // Fallback is provided for backward compatibility and testing - const defaultValue = configDefault !== undefined ? configDefault : fallbackDefault; - - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - module.exports = { - loadSafeOutputsConfig, - getSafeOutputConfig, - validateTitle, - validateBody, - validateLabels, - validateMaxCount, - }; - - EOF_437e6b4f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_COMMENT_TARGET: "*" - GH_AW_WORKFLOW_NAME: "Auto-Assign Issue" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } - } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); - - name: Assign To User - id: assign_to_user - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'assign_to_user')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Auto-Assign Issue" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { processSafeOutput, processItems } = require('/tmp/gh-aw/scripts/safe_output_processor.cjs'); - async function main() { - const result = await processSafeOutput( - { - itemType: "assign_to_user", - configKey: "assign_to_user", - displayName: "Assignees", - itemTypeName: "user assignment", - supportsPR: false, - supportsIssue: true, - envVars: { - allowed: "GH_AW_ASSIGNEES_ALLOWED", - maxCount: "GH_AW_ASSIGNEES_MAX_COUNT", - target: "GH_AW_ASSIGNEES_TARGET", - }, - }, - { - title: "Assign to User", - description: "The following user assignments would be made if staged mode was disabled:", - renderItem: item => { - let content = ""; - if (item.issue_number) { - content += `**Target Issue:** #${item.issue_number}\n\n`; - } else { - content += `**Target:** Current issue\n\n`; - } - if (item.assignees && item.assignees.length > 0) { - content += `**Users to assign:** ${item.assignees.join(", ")}\n\n`; - } else if (item.assignee) { - content += `**User to assign:** ${item.assignee}\n\n`; - } - return content; - }, - } - ); - if (!result.success) { - return; - } - const { item: assignItem, config, targetResult } = result; - if (!config || !targetResult || targetResult.number === undefined) { - core.setFailed("Internal error: config, targetResult, or targetResult.number is undefined"); - return; - } - const { allowed: allowedAssignees, maxCount } = config; - const issueNumber = targetResult.number; - let requestedAssignees = []; - if (assignItem.assignees && Array.isArray(assignItem.assignees)) { - requestedAssignees = assignItem.assignees; - } else if (assignItem.assignee) { - requestedAssignees = [assignItem.assignee]; - } - core.info(`Requested assignees: ${JSON.stringify(requestedAssignees)}`); - const uniqueAssignees = processItems(requestedAssignees, allowedAssignees, maxCount); - if (uniqueAssignees.length === 0) { - core.info("No assignees to add"); - core.setOutput("assigned_users", ""); - await core.summary - .addRaw( - ` - ## User Assignment - No users were assigned (no valid assignees found in agent output). - ` - ) - .write(); - return; - } - core.info(`Assigning ${uniqueAssignees.length} users to issue #${issueNumber}: ${JSON.stringify(uniqueAssignees)}`); + (async () => { try { - const targetRepoEnv = process.env.GH_AW_TARGET_REPO_SLUG?.trim(); - let targetOwner = context.repo.owner; - let targetRepo = context.repo.repo; - if (targetRepoEnv) { - const parts = targetRepoEnv.split("/"); - if (parts.length === 2) { - targetOwner = parts[0]; - targetRepo = parts[1]; - core.info(`Using target repository: ${targetOwner}/${targetRepo}`); - } - } - await github.rest.issues.addAssignees({ - owner: targetOwner, - repo: targetRepo, - issue_number: issueNumber, - assignees: uniqueAssignees, - }); - core.info(`Successfully assigned ${uniqueAssignees.length} user(s) to issue #${issueNumber}`); - core.setOutput("assigned_users", uniqueAssignees.join("\n")); - const assigneesListMarkdown = uniqueAssignees.map(assignee => `- \`${assignee}\``).join("\n"); - await core.summary - .addRaw( - ` - ## User Assignment - Successfully assigned ${uniqueAssignees.length} user(s) to issue #${issueNumber}: - ${assigneesListMarkdown} - ` - ) - .write(); + await main(); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to assign users: ${errorMessage}`); - core.setFailed(`Failed to assign users: ${errorMessage}`); + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); diff --git a/.github/workflows/daily-code-metrics.lock.yml b/.github/workflows/daily-code-metrics.lock.yml index 16924f978b..63f72c5551 100644 --- a/.github/workflows/daily-code-metrics.lock.yml +++ b/.github/workflows/daily-code-metrics.lock.yml @@ -7021,8 +7021,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7035,1174 +7043,178 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Daily Code Metrics and Trend Tracking Agent" - GH_AW_TRACKER_ID: "daily-code-metrics" - GH_AW_ENGINE_ID: "claude" - with: + GH_AW_DISCUSSION_CATEGORY: "audits" +GH_AW_DISCUSSION_EXPIRES=3GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; - } - } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); } } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); update_cache_memory: needs: diff --git a/.github/workflows/daily-copilot-token-report.lock.yml b/.github/workflows/daily-copilot-token-report.lock.yml index 795c46b876..94bb5da902 100644 --- a/.github/workflows/daily-copilot-token-report.lock.yml +++ b/.github/workflows/daily-copilot-token-report.lock.yml @@ -7575,8 +7575,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7589,1313 +7597,178 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Daily Copilot Token Consumption Report" - GH_AW_TRACKER_ID: "daily-copilot-token-report" - GH_AW_ENGINE_ID: "copilot" - with: + GH_AW_DISCUSSION_CATEGORY: "audits" +GH_AW_DISCUSSION_EXPIRES=3GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; } } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); - - name: Upload Assets - id: upload_assets - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'upload_asset')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Daily Copilot Token Consumption Report" - GH_AW_TRACKER_ID: "daily-copilot-token-report" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const path = require("path"); - const crypto = require("crypto"); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function normalizeBranchName(branchName) { - if (!branchName || typeof branchName !== "string" || branchName.trim() === "") { - return branchName; - } - let normalized = branchName.replace(/[^a-zA-Z0-9\-_/.]+/g, "-"); - normalized = normalized.replace(/-+/g, "-"); - normalized = normalized.replace(/^-+|-+$/g, ""); - if (normalized.length > 128) { - normalized = normalized.substring(0, 128); - } - normalized = normalized.replace(/-+$/, ""); - normalized = normalized.toLowerCase(); - return normalized; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const branchName = process.env.GH_AW_ASSETS_BRANCH; - if (!branchName || typeof branchName !== "string") { - core.setFailed("GH_AW_ASSETS_BRANCH environment variable is required but not set"); - return; - } - const normalizedBranchName = normalizeBranchName(branchName); - core.info(`Using assets branch: ${normalizedBranchName}`); - const result = loadAgentOutput(); - if (!result.success) { - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - const uploadItems = result.items.filter( item => item.type === "upload_assets"); - const uploadAssetItems = result.items.filter( item => item.type === "upload_asset"); - const allUploadItems = [...uploadItems, ...uploadAssetItems]; - if (allUploadItems.length === 0) { - core.info("No upload-asset items found in agent output"); - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - core.info(`Found ${allUploadItems.length} upload-asset item(s)`); - let uploadCount = 0; - let hasChanges = false; + (async () => { try { - try { - await exec.exec(`git rev-parse --verify origin/${normalizedBranchName}`); - await exec.exec(`git checkout -B ${normalizedBranchName} origin/${normalizedBranchName}`); - core.info(`Checked out existing branch from origin: ${normalizedBranchName}`); - } catch (originError) { - if (!normalizedBranchName.startsWith("assets/")) { - core.setFailed( - `Branch '${normalizedBranchName}' does not start with the required 'assets/' prefix. ` + - `Orphaned branches can only be automatically created under the 'assets/' prefix. ` + - `Please create the branch manually first, or use a branch name starting with 'assets/'.` - ); - return; - } - core.info(`Creating new orphaned branch: ${normalizedBranchName}`); - await exec.exec(`git checkout --orphan ${normalizedBranchName}`); - await exec.exec(`git rm -rf .`); - await exec.exec(`git clean -fdx`); - } - for (const asset of uploadAssetItems) { - try { - const { fileName, sha, size, targetFileName } = asset; - if (!fileName || !sha || !targetFileName) { - core.error(`Invalid asset entry missing required fields: ${JSON.stringify(asset)}`); - continue; - } - const assetSourcePath = path.join("/tmp/gh-aw/safeoutputs/assets", fileName); - if (!fs.existsSync(assetSourcePath)) { - core.warning(`Asset file not found: ${assetSourcePath}`); - continue; - } - const fileContent = fs.readFileSync(assetSourcePath); - const computedSha = crypto.createHash("sha256").update(fileContent).digest("hex"); - if (computedSha !== sha) { - core.warning(`SHA mismatch for ${fileName}: expected ${sha}, got ${computedSha}`); - continue; - } - if (fs.existsSync(targetFileName)) { - core.info(`Asset ${targetFileName} already exists, skipping`); - continue; - } - fs.copyFileSync(assetSourcePath, targetFileName); - await exec.exec(`git add "${targetFileName}"`); - uploadCount++; - hasChanges = true; - core.info(`Added asset: ${targetFileName} (${size} bytes)`); - } catch (error) { - core.warning(`Failed to process asset ${asset.fileName}: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (hasChanges) { - const commitMessage = `[skip-ci] Add ${uploadCount} asset(s)`; - await exec.exec(`git`, [`commit`, `-m`, commitMessage]); - if (isStaged) { - core.summary.addRaw("## Staged Asset Publication"); - } else { - await exec.exec(`git push origin ${normalizedBranchName}`); - core.summary.addRaw("## Assets").addRaw(`Successfully uploaded **${uploadCount}** assets to branch \`${normalizedBranchName}\``).addRaw(""); - core.info(`Successfully uploaded ${uploadCount} assets to branch ${normalizedBranchName}`); - } - for (const asset of uploadAssetItems) { - if (asset.fileName && asset.sha && asset.size && asset.url) { - core.summary.addRaw(`- [\`${asset.fileName}\`](${asset.url}) → \`${asset.targetFileName}\` (${asset.size} bytes)`); - } - } - core.summary.write(); - } else { - core.info("No new assets to upload"); - } + await main(); } catch (error) { - core.setFailed(`Failed to upload assets: ${error instanceof Error ? error.message : String(error)}`); - return; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - core.setOutput("upload_count", uploadCount.toString()); - core.setOutput("branch_name", normalizedBranchName); - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/daily-doc-updater.lock.yml b/.github/workflows/daily-doc-updater.lock.yml index e62cd24489..8eaebfba1a 100644 --- a/.github/workflows/daily-doc-updater.lock.yml +++ b/.github/workflows/daily-doc-updater.lock.yml @@ -6323,8 +6323,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6337,776 +6345,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Create Pull Request - id: create_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PR_TITLE_PREFIX: "[docs] " GH_AW_PR_LABELS: "documentation,automation" - GH_AW_PR_DRAFT: "false" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Daily Documentation Updater" - GH_AW_TRACKER_ID: "daily-doc-updater" - GH_AW_ENGINE_ID: "claude" - with: + GH_AW_PR_TITLE_PREFIX: "[docs] " +GH_AW_PR_DRAFT=false with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { - core.setOutput("pull_request_number", ""); - core.setOutput("pull_request_url", ""); core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); - core.setOutput("branch_name", ""); - core.setOutput("fallback_used", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const workflowId = process.env.GH_AW_WORKFLOW_ID; - if (!workflowId) { - throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); - } - const baseBranch = process.env.GH_AW_BASE_BRANCH; - if (!baseBranch) { - throw new Error("GH_AW_BASE_BRANCH environment variable is required"); - } - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - let outputContent = ""; - if (agentOutputFile.trim() !== "") { - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - } - const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn"; - const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - let patchContent = ""; - let isEmpty = true; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } - if (patchContent.includes("Failed to generate patch")) { - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return; - } - throw new Error(message); - } - core.info("Patch size validation passed"); - } - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to push - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); return; } - core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - if (!title) { - title = "Agent Output"; - } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - const labelsEnv = process.env.GH_AW_PR_LABELS; - const labels = labelsEnv - ? labelsEnv - .split(",") - .map( label => label.trim()) - .filter( label => label) - : []; - const draftEnv = process.env.GH_AW_PR_DRAFT; - const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); - const randomHex = crypto.randomBytes(8).toString("hex"); - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); } else { - branchName = `${branchName}-${randomHex}`; - core.info(`Using branch name from JSONL with added salt: ${branchName}`); - } - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); - core.info(`Fetching latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${baseBranch}`); - core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`); - await exec.exec(`git checkout -b ${branchName}`); - core.info(`Created new branch from base: ${branchName}`); - if (!isEmpty) { - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - try { - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - try { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Changes pushed to branch"); - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - > [!NOTE] - > This was originally intended as a pull request, but the git push operation failed. - > - > **Workflow Run:** [View run details and download patch artifact](${runUrl}) - > - > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above. - To apply the patch locally: - \`\`\`sh - # Download the artifact from the workflow run ${runUrl} - # (Use GitHub MCP tools if gh CLI is not available) - gh run download ${runId} -n aw.patch - # Apply the patch - git am aw.patch - \`\`\` - ${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - core.setOutput("push_failed", "true"); - await core.summary - .addRaw( - ` - ## Push Failure Fallback - - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} - - **Fallback Issue:** [#${issue.number}](${issue.html_url}) - - **Patch Artifact:** Available in workflow run artifacts - - **Note:** Push failed, created issue as fallback - ` - ) - .write(); - return; - } catch (issueError) { - core.setFailed( - `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` - ); - return; } } - } else { - core.info("Skipping patch application (empty patch)"); - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - try { - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Empty branch pushed successfully"); - } catch (pushError) { - core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - return; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - } else { - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to apply - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } + await core.summary.addRaw(summaryContent).write(); } + core.info(`Completed processing ${result.items.length} safe output item(s)`); + } + (async () => { try { - const { data: pullRequest } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }); - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - labels: labels, - }); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } - core.setOutput("pull_request_number", pullRequest.number); - core.setOutput("pull_request_url", pullRequest.html_url); - core.setOutput("branch_name", branchName); - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - await core.summary - .addRaw( - ` - ## Pull Request - - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - - **Branch**: \`${branchName}\` - - **Base Branch**: \`${baseBranch}\` - ` - ) - .write(); - } catch (prError) { - core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); - core.info("Falling back to creating an issue instead"); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). - **Original error:** ${prError instanceof Error ? prError.message : String(prError)} - You can manually create a pull request from the branch if needed.${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - await core.summary - .addRaw( - ` - ## Fallback Issue Created - - **Issue**: [#${issue.number}](${issue.html_url}) - - **Branch**: [\`${branchName}\`](${branchUrl}) - - **Base Branch**: \`${baseBranch}\` - - **Note**: Pull request creation failed, created issue as fallback - ` - ) - .write(); - } catch (issueError) { - core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`); - return; - } + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/daily-fact.lock.yml b/.github/workflows/daily-fact.lock.yml index 703d8e73e0..e47fadff55 100644 --- a/.github/workflows/daily-fact.lock.yml +++ b/.github/workflows/daily-fact.lock.yml @@ -6111,8 +6111,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6125,1023 +6133,175 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_COMMENT_TARGET: "4750" - GITHUB_AW_COMMENT_DISCUSSION: "true" - GH_AW_WORKFLOW_NAME: "Daily Fact About gh-aw" - GH_AW_TRACKER_ID: "daily-fact-thread" - GH_AW_ENGINE_ID: "codex" - GH_AW_ENGINE_MODEL: "gpt-5-mini" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🪶 *Penned with care by [{workflow_name}]({run_url})*\",\"runStarted\":\"📜 Hark! The muse awakens — [{workflow_name}]({run_url}) begins its verse upon this {event_type}...\",\"runSuccess\":\"✨ Lo! [{workflow_name}]({run_url}) hath woven its tale to completion, like a sonnet finding its final rhyme. 🌟\",\"runFailure\":\"🌧️ Alas! [{workflow_name}]({run_url}) {status}, its quill fallen mid-verse. The poem remains unfinished...\"}" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } - } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); diff --git a/.github/workflows/daily-file-diet.lock.yml b/.github/workflows/daily-file-diet.lock.yml index c7af2d5e26..47fe66293f 100644 --- a/.github/workflows/daily-file-diet.lock.yml +++ b/.github/workflows/daily-file-diet.lock.yml @@ -7956,9 +7956,16 @@ jobs: issues: write timeout-minutes: 15 outputs: - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7982,1086 +7989,179 @@ jobs: github-api-url: ${{ github.api_url }} permission-contents: write permission-issues: write - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_ISSUE_TITLE_PREFIX: "[file-diet] " GH_AW_ISSUE_LABELS: "refactoring,code-health,automated-analysis" - GH_AW_WORKFLOW_NAME: "Daily File Diet" - GH_AW_TRACKER_ID: "daily-file-diet" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ steps.app-token.outputs.token }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; + summaryContent += `**Repository:** ${item.repo}\n\n`; } if (item.body) { - content += `**Body:**\n${item.body}\n\n`; + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; - } - return content; - }, - }); + summaryContent += "---\n\n"; + } + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } - } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } - } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } await core.summary.addRaw(summaryContent).write(); } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } (async () => { - await main(); - })(); - - name: Upload Assets - id: upload_assets - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'upload_asset')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Daily File Diet" - GH_AW_TRACKER_ID: "daily-file-diet" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const path = require("path"); - const crypto = require("crypto"); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function normalizeBranchName(branchName) { - if (!branchName || typeof branchName !== "string" || branchName.trim() === "") { - return branchName; - } - let normalized = branchName.replace(/[^a-zA-Z0-9\-_/.]+/g, "-"); - normalized = normalized.replace(/-+/g, "-"); - normalized = normalized.replace(/^-+|-+$/g, ""); - if (normalized.length > 128) { - normalized = normalized.substring(0, 128); - } - normalized = normalized.replace(/-+$/, ""); - normalized = normalized.toLowerCase(); - return normalized; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const branchName = process.env.GH_AW_ASSETS_BRANCH; - if (!branchName || typeof branchName !== "string") { - core.setFailed("GH_AW_ASSETS_BRANCH environment variable is required but not set"); - return; - } - const normalizedBranchName = normalizeBranchName(branchName); - core.info(`Using assets branch: ${normalizedBranchName}`); - const result = loadAgentOutput(); - if (!result.success) { - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - const uploadItems = result.items.filter( item => item.type === "upload_assets"); - const uploadAssetItems = result.items.filter( item => item.type === "upload_asset"); - const allUploadItems = [...uploadItems, ...uploadAssetItems]; - if (allUploadItems.length === 0) { - core.info("No upload-asset items found in agent output"); - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - core.info(`Found ${allUploadItems.length} upload-asset item(s)`); - let uploadCount = 0; - let hasChanges = false; try { - try { - await exec.exec(`git rev-parse --verify origin/${normalizedBranchName}`); - await exec.exec(`git checkout -B ${normalizedBranchName} origin/${normalizedBranchName}`); - core.info(`Checked out existing branch from origin: ${normalizedBranchName}`); - } catch (originError) { - if (!normalizedBranchName.startsWith("assets/")) { - core.setFailed( - `Branch '${normalizedBranchName}' does not start with the required 'assets/' prefix. ` + - `Orphaned branches can only be automatically created under the 'assets/' prefix. ` + - `Please create the branch manually first, or use a branch name starting with 'assets/'.` - ); - return; - } - core.info(`Creating new orphaned branch: ${normalizedBranchName}`); - await exec.exec(`git checkout --orphan ${normalizedBranchName}`); - await exec.exec(`git rm -rf .`); - await exec.exec(`git clean -fdx`); - } - for (const asset of uploadAssetItems) { - try { - const { fileName, sha, size, targetFileName } = asset; - if (!fileName || !sha || !targetFileName) { - core.error(`Invalid asset entry missing required fields: ${JSON.stringify(asset)}`); - continue; - } - const assetSourcePath = path.join("/tmp/gh-aw/safeoutputs/assets", fileName); - if (!fs.existsSync(assetSourcePath)) { - core.warning(`Asset file not found: ${assetSourcePath}`); - continue; - } - const fileContent = fs.readFileSync(assetSourcePath); - const computedSha = crypto.createHash("sha256").update(fileContent).digest("hex"); - if (computedSha !== sha) { - core.warning(`SHA mismatch for ${fileName}: expected ${sha}, got ${computedSha}`); - continue; - } - if (fs.existsSync(targetFileName)) { - core.info(`Asset ${targetFileName} already exists, skipping`); - continue; - } - fs.copyFileSync(assetSourcePath, targetFileName); - await exec.exec(`git add "${targetFileName}"`); - uploadCount++; - hasChanges = true; - core.info(`Added asset: ${targetFileName} (${size} bytes)`); - } catch (error) { - core.warning(`Failed to process asset ${asset.fileName}: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (hasChanges) { - const commitMessage = `[skip-ci] Add ${uploadCount} asset(s)`; - await exec.exec(`git`, [`commit`, `-m`, commitMessage]); - if (isStaged) { - core.summary.addRaw("## Staged Asset Publication"); - } else { - await exec.exec(`git push origin ${normalizedBranchName}`); - core.summary.addRaw("## Assets").addRaw(`Successfully uploaded **${uploadCount}** assets to branch \`${normalizedBranchName}\``).addRaw(""); - core.info(`Successfully uploaded ${uploadCount} assets to branch ${normalizedBranchName}`); - } - for (const asset of uploadAssetItems) { - if (asset.fileName && asset.sha && asset.size && asset.url) { - core.summary.addRaw(`- [\`${asset.fileName}\`](${asset.url}) → \`${asset.targetFileName}\` (${asset.size} bytes)`); - } - } - core.summary.write(); - } else { - core.info("No new assets to upload"); - } + await main(); } catch (error) { - core.setFailed(`Failed to upload assets: ${error instanceof Error ? error.message : String(error)}`); - return; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - core.setOutput("upload_count", uploadCount.toString()); - core.setOutput("branch_name", normalizedBranchName); - } - (async () => { await main(); })(); + })(); - name: Invalidate GitHub App token if: always() && steps.app-token.outputs.token != '' env: diff --git a/.github/workflows/daily-firewall-report.lock.yml b/.github/workflows/daily-firewall-report.lock.yml index 5670248043..7d3bdb9b79 100644 --- a/.github/workflows/daily-firewall-report.lock.yml +++ b/.github/workflows/daily-firewall-report.lock.yml @@ -7458,8 +7458,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7472,1313 +7480,178 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Daily Firewall Logs Collector and Reporter" - GH_AW_TRACKER_ID: "daily-firewall-report" - GH_AW_ENGINE_ID: "copilot" - with: + GH_AW_DISCUSSION_CATEGORY: "audits" +GH_AW_DISCUSSION_EXPIRES=3GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; } } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); - } - (async () => { await main(); })(); - - name: Upload Assets - id: upload_assets - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'upload_asset')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Daily Firewall Logs Collector and Reporter" - GH_AW_TRACKER_ID: "daily-firewall-report" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const path = require("path"); - const crypto = require("crypto"); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function normalizeBranchName(branchName) { - if (!branchName || typeof branchName !== "string" || branchName.trim() === "") { - return branchName; - } - let normalized = branchName.replace(/[^a-zA-Z0-9\-_/.]+/g, "-"); - normalized = normalized.replace(/-+/g, "-"); - normalized = normalized.replace(/^-+|-+$/g, ""); - if (normalized.length > 128) { - normalized = normalized.substring(0, 128); - } - normalized = normalized.replace(/-+$/, ""); - normalized = normalized.toLowerCase(); - return normalized; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const branchName = process.env.GH_AW_ASSETS_BRANCH; - if (!branchName || typeof branchName !== "string") { - core.setFailed("GH_AW_ASSETS_BRANCH environment variable is required but not set"); - return; - } - const normalizedBranchName = normalizeBranchName(branchName); - core.info(`Using assets branch: ${normalizedBranchName}`); - const result = loadAgentOutput(); - if (!result.success) { - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - const uploadItems = result.items.filter( item => item.type === "upload_assets"); - const uploadAssetItems = result.items.filter( item => item.type === "upload_asset"); - const allUploadItems = [...uploadItems, ...uploadAssetItems]; - if (allUploadItems.length === 0) { - core.info("No upload-asset items found in agent output"); - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - core.info(`Found ${allUploadItems.length} upload-asset item(s)`); - let uploadCount = 0; - let hasChanges = false; + (async () => { try { - try { - await exec.exec(`git rev-parse --verify origin/${normalizedBranchName}`); - await exec.exec(`git checkout -B ${normalizedBranchName} origin/${normalizedBranchName}`); - core.info(`Checked out existing branch from origin: ${normalizedBranchName}`); - } catch (originError) { - if (!normalizedBranchName.startsWith("assets/")) { - core.setFailed( - `Branch '${normalizedBranchName}' does not start with the required 'assets/' prefix. ` + - `Orphaned branches can only be automatically created under the 'assets/' prefix. ` + - `Please create the branch manually first, or use a branch name starting with 'assets/'.` - ); - return; - } - core.info(`Creating new orphaned branch: ${normalizedBranchName}`); - await exec.exec(`git checkout --orphan ${normalizedBranchName}`); - await exec.exec(`git rm -rf .`); - await exec.exec(`git clean -fdx`); - } - for (const asset of uploadAssetItems) { - try { - const { fileName, sha, size, targetFileName } = asset; - if (!fileName || !sha || !targetFileName) { - core.error(`Invalid asset entry missing required fields: ${JSON.stringify(asset)}`); - continue; - } - const assetSourcePath = path.join("/tmp/gh-aw/safeoutputs/assets", fileName); - if (!fs.existsSync(assetSourcePath)) { - core.warning(`Asset file not found: ${assetSourcePath}`); - continue; - } - const fileContent = fs.readFileSync(assetSourcePath); - const computedSha = crypto.createHash("sha256").update(fileContent).digest("hex"); - if (computedSha !== sha) { - core.warning(`SHA mismatch for ${fileName}: expected ${sha}, got ${computedSha}`); - continue; - } - if (fs.existsSync(targetFileName)) { - core.info(`Asset ${targetFileName} already exists, skipping`); - continue; - } - fs.copyFileSync(assetSourcePath, targetFileName); - await exec.exec(`git add "${targetFileName}"`); - uploadCount++; - hasChanges = true; - core.info(`Added asset: ${targetFileName} (${size} bytes)`); - } catch (error) { - core.warning(`Failed to process asset ${asset.fileName}: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (hasChanges) { - const commitMessage = `[skip-ci] Add ${uploadCount} asset(s)`; - await exec.exec(`git`, [`commit`, `-m`, commitMessage]); - if (isStaged) { - core.summary.addRaw("## Staged Asset Publication"); - } else { - await exec.exec(`git push origin ${normalizedBranchName}`); - core.summary.addRaw("## Assets").addRaw(`Successfully uploaded **${uploadCount}** assets to branch \`${normalizedBranchName}\``).addRaw(""); - core.info(`Successfully uploaded ${uploadCount} assets to branch ${normalizedBranchName}`); - } - for (const asset of uploadAssetItems) { - if (asset.fileName && asset.sha && asset.size && asset.url) { - core.summary.addRaw(`- [\`${asset.fileName}\`](${asset.url}) → \`${asset.targetFileName}\` (${asset.size} bytes)`); - } - } - core.summary.write(); - } else { - core.info("No new assets to upload"); - } + await main(); } catch (error) { - core.setFailed(`Failed to upload assets: ${error instanceof Error ? error.message : String(error)}`); - return; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - core.setOutput("upload_count", uploadCount.toString()); - core.setOutput("branch_name", normalizedBranchName); - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/daily-issues-report.lock.yml b/.github/workflows/daily-issues-report.lock.yml index fbaa9bef0f..7b692fd456 100644 --- a/.github/workflows/daily-issues-report.lock.yml +++ b/.github/workflows/daily-issues-report.lock.yml @@ -7288,8 +7288,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7302,1681 +7310,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Daily Issues Report Generator" - GH_AW_TRACKER_ID: "daily-issues-report" - GH_AW_ENGINE_ID: "codex" - with: + GH_AW_DISCUSSION_CATEGORY: "General" + GH_AW_DISCUSSION_TITLE_PREFIX: "[daily issues] " +GH_AW_DISCUSSION_EXPIRES=3GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; - } - summaryContent += "---\n\n"; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); - return; - } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; - } - } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } - } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; - } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } } await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); - } - (async () => { await main(); })(); - - name: Close Discussion - id: close_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'close_discussion')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Daily Issues Report Generator" - GH_AW_TRACKER_ID: "daily-issues-report" - GH_AW_ENGINE_ID: "codex" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - async function getDiscussionDetails(github, owner, repo, discussionNumber) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - title - category { - name - } - labels(first: 100) { - nodes { - name - } - } - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - return repository.discussion; - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussion(github, discussionId, reason) { - const mutation = reason - ? ` - mutation($dId: ID!, $reason: DiscussionCloseReason!) { - closeDiscussion(input: { discussionId: $dId, reason: $reason }) { - discussion { - id - url - } - } - }` - : ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId }) { - discussion { - id - url - } - } - }`; - const variables = reason ? { dId: discussionId, reason } : { dId: discussionId }; - const result = await github.graphql(mutation, variables); - return result.closeDiscussion.discussion; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const result = loadAgentOutput(); - if (!result.success) { + core.info("📝 Safe outputs preview written to step summary"); return; } - const closeDiscussionItems = result.items.filter( item => item.type === "close_discussion"); - if (closeDiscussionItems.length === 0) { - core.info("No close-discussion items found in agent output"); - return; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - core.info(`Found ${closeDiscussionItems.length} close-discussion item(s)`); - const requiredLabels = process.env.GH_AW_CLOSE_DISCUSSION_REQUIRED_LABELS ? process.env.GH_AW_CLOSE_DISCUSSION_REQUIRED_LABELS.split(",").map(l => l.trim()) : []; - const requiredTitlePrefix = process.env.GH_AW_CLOSE_DISCUSSION_REQUIRED_TITLE_PREFIX || ""; - const requiredCategory = process.env.GH_AW_CLOSE_DISCUSSION_REQUIRED_CATEGORY || ""; - const target = process.env.GH_AW_CLOSE_DISCUSSION_TARGET || "triggering"; - core.info(`Configuration: requiredLabels=${requiredLabels.join(",")}, requiredTitlePrefix=${requiredTitlePrefix}, requiredCategory=${requiredCategory}, target=${target}`); - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Close Discussions Preview\n\n"; - summaryContent += "The following discussions would be closed if staged mode was disabled:\n\n"; - for (let i = 0; i < closeDiscussionItems.length; i++) { - const item = closeDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - const discussionNumber = item.discussion_number; - if (discussionNumber) { - const repoUrl = getRepositoryUrl(); - const discussionUrl = `${repoUrl}/discussions/${discussionNumber}`; - summaryContent += `**Target Discussion:** [#${discussionNumber}](${discussionUrl})\n\n`; - } else { - summaryContent += `**Target:** Current discussion\n\n`; - } - if (item.reason) { - summaryContent += `**Reason:** ${item.reason}\n\n`; - } - summaryContent += `**Comment:**\n${item.body || "No content provided"}\n\n`; - if (requiredLabels.length > 0) { - summaryContent += `**Required Labels:** ${requiredLabels.join(", ")}\n\n`; - } - if (requiredTitlePrefix) { - summaryContent += `**Required Title Prefix:** ${requiredTitlePrefix}\n\n`; - } - if (requiredCategory) { - summaryContent += `**Required Category:** ${requiredCategory}\n\n`; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; + } } - summaryContent += "---\n\n"; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion close preview written to step summary"); - return; } - if (target === "triggering" && !isDiscussionContext) { - core.info('Target is "triggering" but not running in discussion context, skipping discussion close'); - return; + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } } - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const closedDiscussions = []; - for (let i = 0; i < closeDiscussionItems.length; i++) { - const item = closeDiscussionItems[i]; - core.info(`Processing close-discussion item ${i + 1}/${closeDiscussionItems.length}: bodyLength=${item.body.length}`); - let discussionNumber; - if (target === "*") { - const targetNumber = item.discussion_number; - if (targetNumber) { - discussionNumber = parseInt(targetNumber, 10); - if (isNaN(discussionNumber) || discussionNumber <= 0) { - core.info(`Invalid discussion number specified: ${targetNumber}`); - continue; - } - } else { - core.info(`Target is "*" but no discussion_number specified in close-discussion item`); - continue; - } - } else if (target && target !== "triggering") { - discussionNumber = parseInt(target, 10); - if (isNaN(discussionNumber) || discussionNumber <= 0) { - core.info(`Invalid discussion number in target configuration: ${target}`); - continue; - } - } else { - if (isDiscussionContext) { - discussionNumber = context.payload.discussion?.number; - if (!discussionNumber) { - core.info("Discussion context detected but no discussion found in payload"); - continue; - } - } else { - core.info("Not in discussion context and no explicit target specified"); - continue; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } } - try { - const discussion = await getDiscussionDetails(github, context.repo.owner, context.repo.repo, discussionNumber); - if (requiredLabels.length > 0) { - const discussionLabels = discussion.labels.nodes.map(l => l.name); - const hasRequiredLabel = requiredLabels.some(required => discussionLabels.includes(required)); - if (!hasRequiredLabel) { - core.info(`Discussion #${discussionNumber} does not have required labels: ${requiredLabels.join(", ")}`); - continue; - } - } - if (requiredTitlePrefix && !discussion.title.startsWith(requiredTitlePrefix)) { - core.info(`Discussion #${discussionNumber} does not have required title prefix: ${requiredTitlePrefix}`); - continue; - } - if (requiredCategory && discussion.category.name !== requiredCategory) { - core.info(`Discussion #${discussionNumber} is not in required category: ${requiredCategory}`); - continue; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } - let body = item.body.trim(); - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - body += getTrackerID("markdown"); - body += generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, undefined, undefined, triggeringDiscussionNumber); - core.info(`Adding comment to discussion #${discussionNumber}`); - core.info(`Comment content length: ${body.length}`); - const comment = await addDiscussionComment(github, discussion.id, body); - core.info("Added discussion comment: " + comment.url); - core.info(`Closing discussion #${discussionNumber} with reason: ${item.reason || "none"}`); - const closedDiscussion = await closeDiscussion(github, discussion.id, item.reason); - core.info("Closed discussion: " + closedDiscussion.url); - closedDiscussions.push({ - number: discussionNumber, - url: discussion.url, - comment_url: comment.url, - }); - if (i === closeDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussionNumber); - core.setOutput("discussion_url", discussion.url); - core.setOutput("comment_url", comment.url); - } - } catch (error) { - core.error(`✗ Failed to close discussion #${discussionNumber}: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } - } - if (closedDiscussions.length > 0) { - let summaryContent = "\n\n## Closed Discussions\n"; - for (const discussion of closedDiscussions) { - summaryContent += `- Discussion #${discussion.number}: [View Discussion](${discussion.url})\n`; - summaryContent += ` - Comment: [View Comment](${discussion.comment_url})\n`; } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully closed ${closedDiscussions.length} discussion(s)`); - return closedDiscussions; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); - - name: Upload Assets - id: upload_assets - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'upload_asset')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Daily Issues Report Generator" - GH_AW_TRACKER_ID: "daily-issues-report" - GH_AW_ENGINE_ID: "codex" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const path = require("path"); - const crypto = require("crypto"); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function normalizeBranchName(branchName) { - if (!branchName || typeof branchName !== "string" || branchName.trim() === "") { - return branchName; - } - let normalized = branchName.replace(/[^a-zA-Z0-9\-_/.]+/g, "-"); - normalized = normalized.replace(/-+/g, "-"); - normalized = normalized.replace(/^-+|-+$/g, ""); - if (normalized.length > 128) { - normalized = normalized.substring(0, 128); - } - normalized = normalized.replace(/-+$/, ""); - normalized = normalized.toLowerCase(); - return normalized; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const branchName = process.env.GH_AW_ASSETS_BRANCH; - if (!branchName || typeof branchName !== "string") { - core.setFailed("GH_AW_ASSETS_BRANCH environment variable is required but not set"); - return; - } - const normalizedBranchName = normalizeBranchName(branchName); - core.info(`Using assets branch: ${normalizedBranchName}`); - const result = loadAgentOutput(); - if (!result.success) { - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - const uploadItems = result.items.filter( item => item.type === "upload_assets"); - const uploadAssetItems = result.items.filter( item => item.type === "upload_asset"); - const allUploadItems = [...uploadItems, ...uploadAssetItems]; - if (allUploadItems.length === 0) { - core.info("No upload-asset items found in agent output"); - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - core.info(`Found ${allUploadItems.length} upload-asset item(s)`); - let uploadCount = 0; - let hasChanges = false; + (async () => { try { - try { - await exec.exec(`git rev-parse --verify origin/${normalizedBranchName}`); - await exec.exec(`git checkout -B ${normalizedBranchName} origin/${normalizedBranchName}`); - core.info(`Checked out existing branch from origin: ${normalizedBranchName}`); - } catch (originError) { - if (!normalizedBranchName.startsWith("assets/")) { - core.setFailed( - `Branch '${normalizedBranchName}' does not start with the required 'assets/' prefix. ` + - `Orphaned branches can only be automatically created under the 'assets/' prefix. ` + - `Please create the branch manually first, or use a branch name starting with 'assets/'.` - ); - return; - } - core.info(`Creating new orphaned branch: ${normalizedBranchName}`); - await exec.exec(`git checkout --orphan ${normalizedBranchName}`); - await exec.exec(`git rm -rf .`); - await exec.exec(`git clean -fdx`); - } - for (const asset of uploadAssetItems) { - try { - const { fileName, sha, size, targetFileName } = asset; - if (!fileName || !sha || !targetFileName) { - core.error(`Invalid asset entry missing required fields: ${JSON.stringify(asset)}`); - continue; - } - const assetSourcePath = path.join("/tmp/gh-aw/safeoutputs/assets", fileName); - if (!fs.existsSync(assetSourcePath)) { - core.warning(`Asset file not found: ${assetSourcePath}`); - continue; - } - const fileContent = fs.readFileSync(assetSourcePath); - const computedSha = crypto.createHash("sha256").update(fileContent).digest("hex"); - if (computedSha !== sha) { - core.warning(`SHA mismatch for ${fileName}: expected ${sha}, got ${computedSha}`); - continue; - } - if (fs.existsSync(targetFileName)) { - core.info(`Asset ${targetFileName} already exists, skipping`); - continue; - } - fs.copyFileSync(assetSourcePath, targetFileName); - await exec.exec(`git add "${targetFileName}"`); - uploadCount++; - hasChanges = true; - core.info(`Added asset: ${targetFileName} (${size} bytes)`); - } catch (error) { - core.warning(`Failed to process asset ${asset.fileName}: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (hasChanges) { - const commitMessage = `[skip-ci] Add ${uploadCount} asset(s)`; - await exec.exec(`git`, [`commit`, `-m`, commitMessage]); - if (isStaged) { - core.summary.addRaw("## Staged Asset Publication"); - } else { - await exec.exec(`git push origin ${normalizedBranchName}`); - core.summary.addRaw("## Assets").addRaw(`Successfully uploaded **${uploadCount}** assets to branch \`${normalizedBranchName}\``).addRaw(""); - core.info(`Successfully uploaded ${uploadCount} assets to branch ${normalizedBranchName}`); - } - for (const asset of uploadAssetItems) { - if (asset.fileName && asset.sha && asset.size && asset.url) { - core.summary.addRaw(`- [\`${asset.fileName}\`](${asset.url}) → \`${asset.targetFileName}\` (${asset.size} bytes)`); - } - } - core.summary.write(); - } else { - core.info("No new assets to upload"); - } + await main(); } catch (error) { - core.setFailed(`Failed to upload assets: ${error instanceof Error ? error.message : String(error)}`); - return; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - core.setOutput("upload_count", uploadCount.toString()); - core.setOutput("branch_name", normalizedBranchName); - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/daily-malicious-code-scan.lock.yml b/.github/workflows/daily-malicious-code-scan.lock.yml index c00a28a133..c187952d64 100644 --- a/.github/workflows/daily-malicious-code-scan.lock.yml +++ b/.github/workflows/daily-malicious-code-scan.lock.yml @@ -6546,6 +6546,17 @@ jobs: contents: read security-events: write timeout-minutes: 15 + outputs: + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6558,306 +6569,175 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - - name: Create Code Scanning Alert - id: create_code_scanning_alert - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_code_scanning_alert')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_FILENAME: "daily-malicious-code-scan" - GH_AW_WORKFLOW_NAME: "Daily Malicious Code Scan Agent" - GH_AW_TRACKER_ID: "malicious-code-scan" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const securityItems = result.items.filter( item => item.type === "create_code_scanning_alert"); - if (securityItems.length === 0) { - core.info("No create-code-scanning-alert items found in agent output"); - return; - } - core.info(`Found ${securityItems.length} create-code-scanning-alert item(s)`); - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Code Scanning Alerts Preview\n\n"; - summaryContent += "The following code scanning alerts would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < securityItems.length; i++) { - const item = securityItems[i]; - summaryContent += `### Security Finding ${i + 1}\n`; - summaryContent += `**File:** ${item.file || "No file provided"}\n\n`; - summaryContent += `**Line:** ${item.line || "No line provided"}\n\n`; - summaryContent += `**Severity:** ${item.severity || "No severity provided"}\n\n`; - summaryContent += `**Message:**\n${item.message || "No message provided"}\n\n`; - summaryContent += "---\n\n"; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; + } } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Code scanning alert creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const maxFindings = process.env.GH_AW_SECURITY_REPORT_MAX ? parseInt(process.env.GH_AW_SECURITY_REPORT_MAX) : 0; - core.info(`Max findings configuration: ${maxFindings === 0 ? "unlimited" : maxFindings}`); - const driverName = process.env.GH_AW_SECURITY_REPORT_DRIVER || "GitHub Agentic Workflows Security Scanner"; - core.info(`Driver name: ${driverName}`); - const workflowFilename = process.env.GH_AW_WORKFLOW_FILENAME || "workflow"; - core.info(`Workflow filename for rule ID prefix: ${workflowFilename}`); - const validFindings = []; - for (let i = 0; i < securityItems.length; i++) { - const securityItem = securityItems[i]; - core.info( - `Processing create-code-scanning-alert item ${i + 1}/${securityItems.length}: file=${securityItem.file}, line=${securityItem.line}, severity=${securityItem.severity}, messageLength=${securityItem.message ? securityItem.message.length : "undefined"}, ruleIdSuffix=${securityItem.ruleIdSuffix || "not specified"}` - ); - if (!securityItem.file) { - core.info('Missing required field "file" in code scanning alert item'); - continue; - } - if (!securityItem.line || (typeof securityItem.line !== "number" && typeof securityItem.line !== "string")) { - core.info('Missing or invalid required field "line" in code scanning alert item'); - continue; - } - if (!securityItem.severity || typeof securityItem.severity !== "string") { - core.info('Missing or invalid required field "severity" in code scanning alert item'); - continue; - } - if (!securityItem.message || typeof securityItem.message !== "string") { - core.info('Missing or invalid required field "message" in code scanning alert item'); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; + } + } } - const line = parseInt(securityItem.line, 10); - if (isNaN(line) || line <= 0) { - core.info(`Invalid line number: ${securityItem.line}`); - continue; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } - let column = 1; - if (securityItem.column !== undefined) { - if (typeof securityItem.column !== "number" && typeof securityItem.column !== "string") { - core.info('Invalid field "column" in code scanning alert item (must be number or string)'); - continue; - } - const parsedColumn = parseInt(securityItem.column, 10); - if (isNaN(parsedColumn) || parsedColumn <= 0) { - core.info(`Invalid column number: ${securityItem.column}`); - continue; + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - column = parsedColumn; } - let ruleIdSuffix = null; - if (securityItem.ruleIdSuffix !== undefined) { - if (typeof securityItem.ruleIdSuffix !== "string") { - core.info('Invalid field "ruleIdSuffix" in code scanning alert item (must be string)'); - continue; - } - const trimmedSuffix = securityItem.ruleIdSuffix.trim(); - if (trimmedSuffix.length === 0) { - core.info('Invalid field "ruleIdSuffix" in code scanning alert item (cannot be empty)'); - continue; - } - if (!/^[a-zA-Z0-9_-]+$/.test(trimmedSuffix)) { - core.info(`Invalid ruleIdSuffix "${trimmedSuffix}" (must contain only alphanumeric characters, hyphens, and underscores)`); - continue; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } - ruleIdSuffix = trimmedSuffix; } - const severityMap = { - error: "error", - warning: "warning", - info: "note", - note: "note", - }; - const normalizedSeverity = securityItem.severity.toLowerCase(); - if (!severityMap[normalizedSeverity]) { - core.info(`Invalid severity level: ${securityItem.severity} (must be error, warning, info, or note)`); - continue; - } - const sarifLevel = severityMap[normalizedSeverity]; - validFindings.push({ - file: securityItem.file.trim(), - line: line, - column: column, - severity: normalizedSeverity, - sarifLevel: sarifLevel, - message: securityItem.message.trim(), - ruleIdSuffix: ruleIdSuffix, - }); - if (maxFindings > 0 && validFindings.length >= maxFindings) { - core.info(`Reached maximum findings limit: ${maxFindings}`); - break; - } - } - if (validFindings.length === 0) { - core.info("No valid security findings to report"); - return; + await core.summary.addRaw(summaryContent).write(); } - core.info(`Processing ${validFindings.length} valid security finding(s)`); - const sarifContent = { - $schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", - version: "2.1.0", - runs: [ - { - tool: { - driver: { - name: driverName, - version: "1.0.0", - informationUri: "https://github.com/githubnext/gh-aw", - }, - }, - results: validFindings.map((finding, index) => ({ - ruleId: finding.ruleIdSuffix ? `${workflowFilename}-${finding.ruleIdSuffix}` : `${workflowFilename}-security-finding-${index + 1}`, - message: { text: finding.message }, - level: finding.sarifLevel, - locations: [ - { - physicalLocation: { - artifactLocation: { uri: finding.file }, - region: { - startLine: finding.line, - startColumn: finding.column, - }, - }, - }, - ], - })), - }, - ], - }; - const fs = require("fs"); - const path = require("path"); - const sarifFileName = "code-scanning-alert.sarif"; - const sarifFilePath = path.join(process.cwd(), sarifFileName); + core.info(`Completed processing ${result.items.length} safe output item(s)`); + } + (async () => { try { - fs.writeFileSync(sarifFilePath, JSON.stringify(sarifContent, null, 2)); - core.info(`✓ Created SARIF file: ${sarifFilePath}`); - core.info(`SARIF file size: ${fs.statSync(sarifFilePath).size} bytes`); - core.setOutput("sarif_file", sarifFilePath); - core.setOutput("findings_count", validFindings.length); - core.setOutput("artifact_uploaded", "pending"); - core.setOutput("codeql_uploaded", "pending"); - let summaryContent = "\n\n## Code Scanning Alert\n"; - summaryContent += `Found **${validFindings.length}** security finding(s):\n\n`; - for (const finding of validFindings) { - const emoji = finding.severity === "error" ? "🔴" : finding.severity === "warning" ? "🟡" : "🔵"; - summaryContent += `${emoji} **${finding.severity.toUpperCase()}** in \`${finding.file}:${finding.line}\`: ${finding.message}\n`; - } - summaryContent += `\n📄 SARIF file created: \`${sarifFileName}\`\n`; - summaryContent += `🔍 Findings will be uploaded to GitHub Code Scanning\n`; - await core.summary.addRaw(summaryContent).write(); + await main(); } catch (error) { - core.error(`✗ Failed to create SARIF file: ${error instanceof Error ? error.message : String(error)}`); - throw error; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - core.info(`Successfully created code scanning alert with ${validFindings.length} finding(s)`); - return { - sarifFile: sarifFilePath, - findingsCount: validFindings.length, - findings: validFindings, - }; - } - (async () => { await main(); })(); + })(); diff --git a/.github/workflows/daily-multi-device-docs-tester.lock.yml b/.github/workflows/daily-multi-device-docs-tester.lock.yml index 6b1ba78810..4b11352ca7 100644 --- a/.github/workflows/daily-multi-device-docs-tester.lock.yml +++ b/.github/workflows/daily-multi-device-docs-tester.lock.yml @@ -6297,9 +6297,16 @@ jobs: issues: write timeout-minutes: 15 outputs: - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6312,1082 +6319,175 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Multi-Device Docs Tester" - GH_AW_TRACKER_ID: "daily-multi-device-docs-tester" - GH_AW_ENGINE_ID: "claude" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; + summaryContent += `**Repository:** ${item.repo}\n\n`; } if (item.body) { - content += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } - return content; - }, - }); + summaryContent += "---\n\n"; + } + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } - } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; - } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; } await core.summary.addRaw(summaryContent).write(); } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } (async () => { - await main(); - })(); - - name: Upload Assets - id: upload_assets - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'upload_asset')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Multi-Device Docs Tester" - GH_AW_TRACKER_ID: "daily-multi-device-docs-tester" - GH_AW_ENGINE_ID: "claude" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const path = require("path"); - const crypto = require("crypto"); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function normalizeBranchName(branchName) { - if (!branchName || typeof branchName !== "string" || branchName.trim() === "") { - return branchName; - } - let normalized = branchName.replace(/[^a-zA-Z0-9\-_/.]+/g, "-"); - normalized = normalized.replace(/-+/g, "-"); - normalized = normalized.replace(/^-+|-+$/g, ""); - if (normalized.length > 128) { - normalized = normalized.substring(0, 128); - } - normalized = normalized.replace(/-+$/, ""); - normalized = normalized.toLowerCase(); - return normalized; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const branchName = process.env.GH_AW_ASSETS_BRANCH; - if (!branchName || typeof branchName !== "string") { - core.setFailed("GH_AW_ASSETS_BRANCH environment variable is required but not set"); - return; - } - const normalizedBranchName = normalizeBranchName(branchName); - core.info(`Using assets branch: ${normalizedBranchName}`); - const result = loadAgentOutput(); - if (!result.success) { - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - const uploadItems = result.items.filter( item => item.type === "upload_assets"); - const uploadAssetItems = result.items.filter( item => item.type === "upload_asset"); - const allUploadItems = [...uploadItems, ...uploadAssetItems]; - if (allUploadItems.length === 0) { - core.info("No upload-asset items found in agent output"); - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - core.info(`Found ${allUploadItems.length} upload-asset item(s)`); - let uploadCount = 0; - let hasChanges = false; try { - try { - await exec.exec(`git rev-parse --verify origin/${normalizedBranchName}`); - await exec.exec(`git checkout -B ${normalizedBranchName} origin/${normalizedBranchName}`); - core.info(`Checked out existing branch from origin: ${normalizedBranchName}`); - } catch (originError) { - if (!normalizedBranchName.startsWith("assets/")) { - core.setFailed( - `Branch '${normalizedBranchName}' does not start with the required 'assets/' prefix. ` + - `Orphaned branches can only be automatically created under the 'assets/' prefix. ` + - `Please create the branch manually first, or use a branch name starting with 'assets/'.` - ); - return; - } - core.info(`Creating new orphaned branch: ${normalizedBranchName}`); - await exec.exec(`git checkout --orphan ${normalizedBranchName}`); - await exec.exec(`git rm -rf .`); - await exec.exec(`git clean -fdx`); - } - for (const asset of uploadAssetItems) { - try { - const { fileName, sha, size, targetFileName } = asset; - if (!fileName || !sha || !targetFileName) { - core.error(`Invalid asset entry missing required fields: ${JSON.stringify(asset)}`); - continue; - } - const assetSourcePath = path.join("/tmp/gh-aw/safeoutputs/assets", fileName); - if (!fs.existsSync(assetSourcePath)) { - core.warning(`Asset file not found: ${assetSourcePath}`); - continue; - } - const fileContent = fs.readFileSync(assetSourcePath); - const computedSha = crypto.createHash("sha256").update(fileContent).digest("hex"); - if (computedSha !== sha) { - core.warning(`SHA mismatch for ${fileName}: expected ${sha}, got ${computedSha}`); - continue; - } - if (fs.existsSync(targetFileName)) { - core.info(`Asset ${targetFileName} already exists, skipping`); - continue; - } - fs.copyFileSync(assetSourcePath, targetFileName); - await exec.exec(`git add "${targetFileName}"`); - uploadCount++; - hasChanges = true; - core.info(`Added asset: ${targetFileName} (${size} bytes)`); - } catch (error) { - core.warning(`Failed to process asset ${asset.fileName}: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (hasChanges) { - const commitMessage = `[skip-ci] Add ${uploadCount} asset(s)`; - await exec.exec(`git`, [`commit`, `-m`, commitMessage]); - if (isStaged) { - core.summary.addRaw("## Staged Asset Publication"); - } else { - await exec.exec(`git push origin ${normalizedBranchName}`); - core.summary.addRaw("## Assets").addRaw(`Successfully uploaded **${uploadCount}** assets to branch \`${normalizedBranchName}\``).addRaw(""); - core.info(`Successfully uploaded ${uploadCount} assets to branch ${normalizedBranchName}`); - } - for (const asset of uploadAssetItems) { - if (asset.fileName && asset.sha && asset.size && asset.url) { - core.summary.addRaw(`- [\`${asset.fileName}\`](${asset.url}) → \`${asset.targetFileName}\` (${asset.size} bytes)`); - } - } - core.summary.write(); - } else { - core.info("No new assets to upload"); - } + await main(); } catch (error) { - core.setFailed(`Failed to upload assets: ${error instanceof Error ? error.message : String(error)}`); - return; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - core.setOutput("upload_count", uploadCount.toString()); - core.setOutput("branch_name", normalizedBranchName); - } - (async () => { await main(); })(); + })(); diff --git a/.github/workflows/daily-news.lock.yml b/.github/workflows/daily-news.lock.yml index f87c8d26b1..c5b185b75a 100644 --- a/.github/workflows/daily-news.lock.yml +++ b/.github/workflows/daily-news.lock.yml @@ -7380,8 +7380,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7394,1313 +7402,178 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Daily News" - GH_AW_TRACKER_ID: "daily-news-weekday" - GH_AW_ENGINE_ID: "copilot" - with: + GH_AW_DISCUSSION_CATEGORY: "daily-news" +GH_AW_DISCUSSION_EXPIRES=3GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; } } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); - - name: Upload Assets - id: upload_assets - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'upload_asset')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Daily News" - GH_AW_TRACKER_ID: "daily-news-weekday" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const path = require("path"); - const crypto = require("crypto"); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function normalizeBranchName(branchName) { - if (!branchName || typeof branchName !== "string" || branchName.trim() === "") { - return branchName; - } - let normalized = branchName.replace(/[^a-zA-Z0-9\-_/.]+/g, "-"); - normalized = normalized.replace(/-+/g, "-"); - normalized = normalized.replace(/^-+|-+$/g, ""); - if (normalized.length > 128) { - normalized = normalized.substring(0, 128); - } - normalized = normalized.replace(/-+$/, ""); - normalized = normalized.toLowerCase(); - return normalized; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const branchName = process.env.GH_AW_ASSETS_BRANCH; - if (!branchName || typeof branchName !== "string") { - core.setFailed("GH_AW_ASSETS_BRANCH environment variable is required but not set"); - return; - } - const normalizedBranchName = normalizeBranchName(branchName); - core.info(`Using assets branch: ${normalizedBranchName}`); - const result = loadAgentOutput(); - if (!result.success) { - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - const uploadItems = result.items.filter( item => item.type === "upload_assets"); - const uploadAssetItems = result.items.filter( item => item.type === "upload_asset"); - const allUploadItems = [...uploadItems, ...uploadAssetItems]; - if (allUploadItems.length === 0) { - core.info("No upload-asset items found in agent output"); - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - core.info(`Found ${allUploadItems.length} upload-asset item(s)`); - let uploadCount = 0; - let hasChanges = false; + (async () => { try { - try { - await exec.exec(`git rev-parse --verify origin/${normalizedBranchName}`); - await exec.exec(`git checkout -B ${normalizedBranchName} origin/${normalizedBranchName}`); - core.info(`Checked out existing branch from origin: ${normalizedBranchName}`); - } catch (originError) { - if (!normalizedBranchName.startsWith("assets/")) { - core.setFailed( - `Branch '${normalizedBranchName}' does not start with the required 'assets/' prefix. ` + - `Orphaned branches can only be automatically created under the 'assets/' prefix. ` + - `Please create the branch manually first, or use a branch name starting with 'assets/'.` - ); - return; - } - core.info(`Creating new orphaned branch: ${normalizedBranchName}`); - await exec.exec(`git checkout --orphan ${normalizedBranchName}`); - await exec.exec(`git rm -rf .`); - await exec.exec(`git clean -fdx`); - } - for (const asset of uploadAssetItems) { - try { - const { fileName, sha, size, targetFileName } = asset; - if (!fileName || !sha || !targetFileName) { - core.error(`Invalid asset entry missing required fields: ${JSON.stringify(asset)}`); - continue; - } - const assetSourcePath = path.join("/tmp/gh-aw/safeoutputs/assets", fileName); - if (!fs.existsSync(assetSourcePath)) { - core.warning(`Asset file not found: ${assetSourcePath}`); - continue; - } - const fileContent = fs.readFileSync(assetSourcePath); - const computedSha = crypto.createHash("sha256").update(fileContent).digest("hex"); - if (computedSha !== sha) { - core.warning(`SHA mismatch for ${fileName}: expected ${sha}, got ${computedSha}`); - continue; - } - if (fs.existsSync(targetFileName)) { - core.info(`Asset ${targetFileName} already exists, skipping`); - continue; - } - fs.copyFileSync(assetSourcePath, targetFileName); - await exec.exec(`git add "${targetFileName}"`); - uploadCount++; - hasChanges = true; - core.info(`Added asset: ${targetFileName} (${size} bytes)`); - } catch (error) { - core.warning(`Failed to process asset ${asset.fileName}: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (hasChanges) { - const commitMessage = `[skip-ci] Add ${uploadCount} asset(s)`; - await exec.exec(`git`, [`commit`, `-m`, commitMessage]); - if (isStaged) { - core.summary.addRaw("## Staged Asset Publication"); - } else { - await exec.exec(`git push origin ${normalizedBranchName}`); - core.summary.addRaw("## Assets").addRaw(`Successfully uploaded **${uploadCount}** assets to branch \`${normalizedBranchName}\``).addRaw(""); - core.info(`Successfully uploaded ${uploadCount} assets to branch ${normalizedBranchName}`); - } - for (const asset of uploadAssetItems) { - if (asset.fileName && asset.sha && asset.size && asset.url) { - core.summary.addRaw(`- [\`${asset.fileName}\`](${asset.url}) → \`${asset.targetFileName}\` (${asset.size} bytes)`); - } - } - core.summary.write(); - } else { - core.info("No new assets to upload"); - } + await main(); } catch (error) { - core.setFailed(`Failed to upload assets: ${error instanceof Error ? error.message : String(error)}`); - return; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - core.setOutput("upload_count", uploadCount.toString()); - core.setOutput("branch_name", normalizedBranchName); - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/daily-performance-summary.lock.yml b/.github/workflows/daily-performance-summary.lock.yml index 00c42c4972..c8bee6a9a0 100644 --- a/.github/workflows/daily-performance-summary.lock.yml +++ b/.github/workflows/daily-performance-summary.lock.yml @@ -8751,8 +8751,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -8765,1681 +8773,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Daily Project Performance Summary Generator (Using Safe Inputs)" - GH_AW_TRACKER_ID: "daily-performance-summary" - GH_AW_ENGINE_ID: "codex" - with: + GH_AW_DISCUSSION_CATEGORY: "General" + GH_AW_DISCUSSION_TITLE_PREFIX: "[daily performance] " +GH_AW_DISCUSSION_EXPIRES=3GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; - } - summaryContent += "---\n\n"; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); - return; - } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; - } - } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } - } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; - } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } } await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); - } - (async () => { await main(); })(); - - name: Close Discussion - id: close_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'close_discussion')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Daily Project Performance Summary Generator (Using Safe Inputs)" - GH_AW_TRACKER_ID: "daily-performance-summary" - GH_AW_ENGINE_ID: "codex" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - async function getDiscussionDetails(github, owner, repo, discussionNumber) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - title - category { - name - } - labels(first: 100) { - nodes { - name - } - } - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - return repository.discussion; - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussion(github, discussionId, reason) { - const mutation = reason - ? ` - mutation($dId: ID!, $reason: DiscussionCloseReason!) { - closeDiscussion(input: { discussionId: $dId, reason: $reason }) { - discussion { - id - url - } - } - }` - : ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId }) { - discussion { - id - url - } - } - }`; - const variables = reason ? { dId: discussionId, reason } : { dId: discussionId }; - const result = await github.graphql(mutation, variables); - return result.closeDiscussion.discussion; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const result = loadAgentOutput(); - if (!result.success) { + core.info("📝 Safe outputs preview written to step summary"); return; } - const closeDiscussionItems = result.items.filter( item => item.type === "close_discussion"); - if (closeDiscussionItems.length === 0) { - core.info("No close-discussion items found in agent output"); - return; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - core.info(`Found ${closeDiscussionItems.length} close-discussion item(s)`); - const requiredLabels = process.env.GH_AW_CLOSE_DISCUSSION_REQUIRED_LABELS ? process.env.GH_AW_CLOSE_DISCUSSION_REQUIRED_LABELS.split(",").map(l => l.trim()) : []; - const requiredTitlePrefix = process.env.GH_AW_CLOSE_DISCUSSION_REQUIRED_TITLE_PREFIX || ""; - const requiredCategory = process.env.GH_AW_CLOSE_DISCUSSION_REQUIRED_CATEGORY || ""; - const target = process.env.GH_AW_CLOSE_DISCUSSION_TARGET || "triggering"; - core.info(`Configuration: requiredLabels=${requiredLabels.join(",")}, requiredTitlePrefix=${requiredTitlePrefix}, requiredCategory=${requiredCategory}, target=${target}`); - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Close Discussions Preview\n\n"; - summaryContent += "The following discussions would be closed if staged mode was disabled:\n\n"; - for (let i = 0; i < closeDiscussionItems.length; i++) { - const item = closeDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - const discussionNumber = item.discussion_number; - if (discussionNumber) { - const repoUrl = getRepositoryUrl(); - const discussionUrl = `${repoUrl}/discussions/${discussionNumber}`; - summaryContent += `**Target Discussion:** [#${discussionNumber}](${discussionUrl})\n\n`; - } else { - summaryContent += `**Target:** Current discussion\n\n`; - } - if (item.reason) { - summaryContent += `**Reason:** ${item.reason}\n\n`; - } - summaryContent += `**Comment:**\n${item.body || "No content provided"}\n\n`; - if (requiredLabels.length > 0) { - summaryContent += `**Required Labels:** ${requiredLabels.join(", ")}\n\n`; - } - if (requiredTitlePrefix) { - summaryContent += `**Required Title Prefix:** ${requiredTitlePrefix}\n\n`; - } - if (requiredCategory) { - summaryContent += `**Required Category:** ${requiredCategory}\n\n`; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; + } } - summaryContent += "---\n\n"; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion close preview written to step summary"); - return; } - if (target === "triggering" && !isDiscussionContext) { - core.info('Target is "triggering" but not running in discussion context, skipping discussion close'); - return; + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } } - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const closedDiscussions = []; - for (let i = 0; i < closeDiscussionItems.length; i++) { - const item = closeDiscussionItems[i]; - core.info(`Processing close-discussion item ${i + 1}/${closeDiscussionItems.length}: bodyLength=${item.body.length}`); - let discussionNumber; - if (target === "*") { - const targetNumber = item.discussion_number; - if (targetNumber) { - discussionNumber = parseInt(targetNumber, 10); - if (isNaN(discussionNumber) || discussionNumber <= 0) { - core.info(`Invalid discussion number specified: ${targetNumber}`); - continue; - } - } else { - core.info(`Target is "*" but no discussion_number specified in close-discussion item`); - continue; - } - } else if (target && target !== "triggering") { - discussionNumber = parseInt(target, 10); - if (isNaN(discussionNumber) || discussionNumber <= 0) { - core.info(`Invalid discussion number in target configuration: ${target}`); - continue; - } - } else { - if (isDiscussionContext) { - discussionNumber = context.payload.discussion?.number; - if (!discussionNumber) { - core.info("Discussion context detected but no discussion found in payload"); - continue; - } - } else { - core.info("Not in discussion context and no explicit target specified"); - continue; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } } - try { - const discussion = await getDiscussionDetails(github, context.repo.owner, context.repo.repo, discussionNumber); - if (requiredLabels.length > 0) { - const discussionLabels = discussion.labels.nodes.map(l => l.name); - const hasRequiredLabel = requiredLabels.some(required => discussionLabels.includes(required)); - if (!hasRequiredLabel) { - core.info(`Discussion #${discussionNumber} does not have required labels: ${requiredLabels.join(", ")}`); - continue; - } - } - if (requiredTitlePrefix && !discussion.title.startsWith(requiredTitlePrefix)) { - core.info(`Discussion #${discussionNumber} does not have required title prefix: ${requiredTitlePrefix}`); - continue; - } - if (requiredCategory && discussion.category.name !== requiredCategory) { - core.info(`Discussion #${discussionNumber} is not in required category: ${requiredCategory}`); - continue; - } - let body = item.body.trim(); - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - body += getTrackerID("markdown"); - body += generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, undefined, undefined, triggeringDiscussionNumber); - core.info(`Adding comment to discussion #${discussionNumber}`); - core.info(`Comment content length: ${body.length}`); - const comment = await addDiscussionComment(github, discussion.id, body); - core.info("Added discussion comment: " + comment.url); - core.info(`Closing discussion #${discussionNumber} with reason: ${item.reason || "none"}`); - const closedDiscussion = await closeDiscussion(github, discussion.id, item.reason); - core.info("Closed discussion: " + closedDiscussion.url); - closedDiscussions.push({ - number: discussionNumber, - url: discussion.url, - comment_url: comment.url, - }); - if (i === closeDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussionNumber); - core.setOutput("discussion_url", discussion.url); - core.setOutput("comment_url", comment.url); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } - } catch (error) { - core.error(`✗ Failed to close discussion #${discussionNumber}: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } - } - if (closedDiscussions.length > 0) { - let summaryContent = "\n\n## Closed Discussions\n"; - for (const discussion of closedDiscussions) { - summaryContent += `- Discussion #${discussion.number}: [View Discussion](${discussion.url})\n`; - summaryContent += ` - Comment: [View Comment](${discussion.comment_url})\n`; } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully closed ${closedDiscussions.length} discussion(s)`); - return closedDiscussions; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); - - name: Upload Assets - id: upload_assets - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'upload_asset')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Daily Project Performance Summary Generator (Using Safe Inputs)" - GH_AW_TRACKER_ID: "daily-performance-summary" - GH_AW_ENGINE_ID: "codex" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const path = require("path"); - const crypto = require("crypto"); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function normalizeBranchName(branchName) { - if (!branchName || typeof branchName !== "string" || branchName.trim() === "") { - return branchName; - } - let normalized = branchName.replace(/[^a-zA-Z0-9\-_/.]+/g, "-"); - normalized = normalized.replace(/-+/g, "-"); - normalized = normalized.replace(/^-+|-+$/g, ""); - if (normalized.length > 128) { - normalized = normalized.substring(0, 128); - } - normalized = normalized.replace(/-+$/, ""); - normalized = normalized.toLowerCase(); - return normalized; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const branchName = process.env.GH_AW_ASSETS_BRANCH; - if (!branchName || typeof branchName !== "string") { - core.setFailed("GH_AW_ASSETS_BRANCH environment variable is required but not set"); - return; - } - const normalizedBranchName = normalizeBranchName(branchName); - core.info(`Using assets branch: ${normalizedBranchName}`); - const result = loadAgentOutput(); - if (!result.success) { - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - const uploadItems = result.items.filter( item => item.type === "upload_assets"); - const uploadAssetItems = result.items.filter( item => item.type === "upload_asset"); - const allUploadItems = [...uploadItems, ...uploadAssetItems]; - if (allUploadItems.length === 0) { - core.info("No upload-asset items found in agent output"); - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - core.info(`Found ${allUploadItems.length} upload-asset item(s)`); - let uploadCount = 0; - let hasChanges = false; + (async () => { try { - try { - await exec.exec(`git rev-parse --verify origin/${normalizedBranchName}`); - await exec.exec(`git checkout -B ${normalizedBranchName} origin/${normalizedBranchName}`); - core.info(`Checked out existing branch from origin: ${normalizedBranchName}`); - } catch (originError) { - if (!normalizedBranchName.startsWith("assets/")) { - core.setFailed( - `Branch '${normalizedBranchName}' does not start with the required 'assets/' prefix. ` + - `Orphaned branches can only be automatically created under the 'assets/' prefix. ` + - `Please create the branch manually first, or use a branch name starting with 'assets/'.` - ); - return; - } - core.info(`Creating new orphaned branch: ${normalizedBranchName}`); - await exec.exec(`git checkout --orphan ${normalizedBranchName}`); - await exec.exec(`git rm -rf .`); - await exec.exec(`git clean -fdx`); - } - for (const asset of uploadAssetItems) { - try { - const { fileName, sha, size, targetFileName } = asset; - if (!fileName || !sha || !targetFileName) { - core.error(`Invalid asset entry missing required fields: ${JSON.stringify(asset)}`); - continue; - } - const assetSourcePath = path.join("/tmp/gh-aw/safeoutputs/assets", fileName); - if (!fs.existsSync(assetSourcePath)) { - core.warning(`Asset file not found: ${assetSourcePath}`); - continue; - } - const fileContent = fs.readFileSync(assetSourcePath); - const computedSha = crypto.createHash("sha256").update(fileContent).digest("hex"); - if (computedSha !== sha) { - core.warning(`SHA mismatch for ${fileName}: expected ${sha}, got ${computedSha}`); - continue; - } - if (fs.existsSync(targetFileName)) { - core.info(`Asset ${targetFileName} already exists, skipping`); - continue; - } - fs.copyFileSync(assetSourcePath, targetFileName); - await exec.exec(`git add "${targetFileName}"`); - uploadCount++; - hasChanges = true; - core.info(`Added asset: ${targetFileName} (${size} bytes)`); - } catch (error) { - core.warning(`Failed to process asset ${asset.fileName}: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (hasChanges) { - const commitMessage = `[skip-ci] Add ${uploadCount} asset(s)`; - await exec.exec(`git`, [`commit`, `-m`, commitMessage]); - if (isStaged) { - core.summary.addRaw("## Staged Asset Publication"); - } else { - await exec.exec(`git push origin ${normalizedBranchName}`); - core.summary.addRaw("## Assets").addRaw(`Successfully uploaded **${uploadCount}** assets to branch \`${normalizedBranchName}\``).addRaw(""); - core.info(`Successfully uploaded ${uploadCount} assets to branch ${normalizedBranchName}`); - } - for (const asset of uploadAssetItems) { - if (asset.fileName && asset.sha && asset.size && asset.url) { - core.summary.addRaw(`- [\`${asset.fileName}\`](${asset.url}) → \`${asset.targetFileName}\` (${asset.size} bytes)`); - } - } - core.summary.write(); - } else { - core.info("No new assets to upload"); - } + await main(); } catch (error) { - core.setFailed(`Failed to upload assets: ${error instanceof Error ? error.message : String(error)}`); - return; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - core.setOutput("upload_count", uploadCount.toString()); - core.setOutput("branch_name", normalizedBranchName); - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/daily-repo-chronicle.lock.yml b/.github/workflows/daily-repo-chronicle.lock.yml index 37297aac5c..7255bcd4e7 100644 --- a/.github/workflows/daily-repo-chronicle.lock.yml +++ b/.github/workflows/daily-repo-chronicle.lock.yml @@ -7301,8 +7301,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7315,1313 +7323,178 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "The Daily Repository Chronicle" - GH_AW_TRACKER_ID: "daily-repo-chronicle" - GH_AW_ENGINE_ID: "copilot" - with: + GH_AW_DISCUSSION_TITLE_PREFIX: "📰 " +GH_AW_DISCUSSION_EXPIRES=3GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; } } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); - - name: Upload Assets - id: upload_assets - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'upload_asset')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "The Daily Repository Chronicle" - GH_AW_TRACKER_ID: "daily-repo-chronicle" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const path = require("path"); - const crypto = require("crypto"); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function normalizeBranchName(branchName) { - if (!branchName || typeof branchName !== "string" || branchName.trim() === "") { - return branchName; - } - let normalized = branchName.replace(/[^a-zA-Z0-9\-_/.]+/g, "-"); - normalized = normalized.replace(/-+/g, "-"); - normalized = normalized.replace(/^-+|-+$/g, ""); - if (normalized.length > 128) { - normalized = normalized.substring(0, 128); - } - normalized = normalized.replace(/-+$/, ""); - normalized = normalized.toLowerCase(); - return normalized; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const branchName = process.env.GH_AW_ASSETS_BRANCH; - if (!branchName || typeof branchName !== "string") { - core.setFailed("GH_AW_ASSETS_BRANCH environment variable is required but not set"); - return; - } - const normalizedBranchName = normalizeBranchName(branchName); - core.info(`Using assets branch: ${normalizedBranchName}`); - const result = loadAgentOutput(); - if (!result.success) { - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - const uploadItems = result.items.filter( item => item.type === "upload_assets"); - const uploadAssetItems = result.items.filter( item => item.type === "upload_asset"); - const allUploadItems = [...uploadItems, ...uploadAssetItems]; - if (allUploadItems.length === 0) { - core.info("No upload-asset items found in agent output"); - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - core.info(`Found ${allUploadItems.length} upload-asset item(s)`); - let uploadCount = 0; - let hasChanges = false; + (async () => { try { - try { - await exec.exec(`git rev-parse --verify origin/${normalizedBranchName}`); - await exec.exec(`git checkout -B ${normalizedBranchName} origin/${normalizedBranchName}`); - core.info(`Checked out existing branch from origin: ${normalizedBranchName}`); - } catch (originError) { - if (!normalizedBranchName.startsWith("assets/")) { - core.setFailed( - `Branch '${normalizedBranchName}' does not start with the required 'assets/' prefix. ` + - `Orphaned branches can only be automatically created under the 'assets/' prefix. ` + - `Please create the branch manually first, or use a branch name starting with 'assets/'.` - ); - return; - } - core.info(`Creating new orphaned branch: ${normalizedBranchName}`); - await exec.exec(`git checkout --orphan ${normalizedBranchName}`); - await exec.exec(`git rm -rf .`); - await exec.exec(`git clean -fdx`); - } - for (const asset of uploadAssetItems) { - try { - const { fileName, sha, size, targetFileName } = asset; - if (!fileName || !sha || !targetFileName) { - core.error(`Invalid asset entry missing required fields: ${JSON.stringify(asset)}`); - continue; - } - const assetSourcePath = path.join("/tmp/gh-aw/safeoutputs/assets", fileName); - if (!fs.existsSync(assetSourcePath)) { - core.warning(`Asset file not found: ${assetSourcePath}`); - continue; - } - const fileContent = fs.readFileSync(assetSourcePath); - const computedSha = crypto.createHash("sha256").update(fileContent).digest("hex"); - if (computedSha !== sha) { - core.warning(`SHA mismatch for ${fileName}: expected ${sha}, got ${computedSha}`); - continue; - } - if (fs.existsSync(targetFileName)) { - core.info(`Asset ${targetFileName} already exists, skipping`); - continue; - } - fs.copyFileSync(assetSourcePath, targetFileName); - await exec.exec(`git add "${targetFileName}"`); - uploadCount++; - hasChanges = true; - core.info(`Added asset: ${targetFileName} (${size} bytes)`); - } catch (error) { - core.warning(`Failed to process asset ${asset.fileName}: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (hasChanges) { - const commitMessage = `[skip-ci] Add ${uploadCount} asset(s)`; - await exec.exec(`git`, [`commit`, `-m`, commitMessage]); - if (isStaged) { - core.summary.addRaw("## Staged Asset Publication"); - } else { - await exec.exec(`git push origin ${normalizedBranchName}`); - core.summary.addRaw("## Assets").addRaw(`Successfully uploaded **${uploadCount}** assets to branch \`${normalizedBranchName}\``).addRaw(""); - core.info(`Successfully uploaded ${uploadCount} assets to branch ${normalizedBranchName}`); - } - for (const asset of uploadAssetItems) { - if (asset.fileName && asset.sha && asset.size && asset.url) { - core.summary.addRaw(`- [\`${asset.fileName}\`](${asset.url}) → \`${asset.targetFileName}\` (${asset.size} bytes)`); - } - } - core.summary.write(); - } else { - core.info("No new assets to upload"); - } + await main(); } catch (error) { - core.setFailed(`Failed to upload assets: ${error instanceof Error ? error.message : String(error)}`); - return; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - core.setOutput("upload_count", uploadCount.toString()); - core.setOutput("branch_name", normalizedBranchName); - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/daily-workflow-updater.lock.yml b/.github/workflows/daily-workflow-updater.lock.yml index 07c3b92f01..48e8db7790 100644 --- a/.github/workflows/daily-workflow-updater.lock.yml +++ b/.github/workflows/daily-workflow-updater.lock.yml @@ -6648,8 +6648,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6662,774 +6670,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Create Pull Request - id: create_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PR_TITLE_PREFIX: "[actions] " GH_AW_PR_LABELS: "dependencies,automation" - GH_AW_PR_DRAFT: "false" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Daily Workflow Updater" - GH_AW_TRACKER_ID: "daily-workflow-updater" - GH_AW_ENGINE_ID: "copilot" - with: + GH_AW_PR_TITLE_PREFIX: "[actions] " +GH_AW_PR_DRAFT=false with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { - core.setOutput("pull_request_number", ""); - core.setOutput("pull_request_url", ""); core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); - core.setOutput("branch_name", ""); - core.setOutput("fallback_used", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const workflowId = process.env.GH_AW_WORKFLOW_ID; - if (!workflowId) { - throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); - } - const baseBranch = process.env.GH_AW_BASE_BRANCH; - if (!baseBranch) { - throw new Error("GH_AW_BASE_BRANCH environment variable is required"); - } - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - let outputContent = ""; - if (agentOutputFile.trim() !== "") { - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - } - const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn"; - const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - let patchContent = ""; - let isEmpty = true; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } - if (patchContent.includes("Failed to generate patch")) { - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return; - } - throw new Error(message); - } - core.info("Patch size validation passed"); - } - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to push - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); return; } - core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - if (!title) { - title = "Agent Output"; - } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - const labelsEnv = process.env.GH_AW_PR_LABELS; - const labels = labelsEnv - ? labelsEnv - .split(",") - .map( label => label.trim()) - .filter( label => label) - : []; - const draftEnv = process.env.GH_AW_PR_DRAFT; - const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); - const randomHex = crypto.randomBytes(8).toString("hex"); - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); } else { - branchName = `${branchName}-${randomHex}`; - core.info(`Using branch name from JSONL with added salt: ${branchName}`); - } - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); - core.info(`Fetching latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${baseBranch}`); - core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`); - await exec.exec(`git checkout -b ${branchName}`); - core.info(`Created new branch from base: ${branchName}`); - if (!isEmpty) { - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - try { - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - try { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Changes pushed to branch"); - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - > [!NOTE] - > This was originally intended as a pull request, but the git push operation failed. - > - > **Workflow Run:** [View run details and download patch artifact](${runUrl}) - > - > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above. - To apply the patch locally: - \`\`\`sh - # Download the artifact from the workflow run ${runUrl} - # (Use GitHub MCP tools if gh CLI is not available) - gh run download ${runId} -n aw.patch - # Apply the patch - git am aw.patch - \`\`\` - ${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - core.setOutput("push_failed", "true"); - await core.summary - .addRaw( - ` - ## Push Failure Fallback - - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} - - **Fallback Issue:** [#${issue.number}](${issue.html_url}) - - **Patch Artifact:** Available in workflow run artifacts - - **Note:** Push failed, created issue as fallback - ` - ) - .write(); - return; - } catch (issueError) { - core.setFailed( - `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` - ); - return; } } - } else { - core.info("Skipping patch application (empty patch)"); - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - try { - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Empty branch pushed successfully"); - } catch (pushError) { - core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - return; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - } else { - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to apply - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } + await core.summary.addRaw(summaryContent).write(); } + core.info(`Completed processing ${result.items.length} safe output item(s)`); + } + (async () => { try { - const { data: pullRequest } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }); - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - labels: labels, - }); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } - core.setOutput("pull_request_number", pullRequest.number); - core.setOutput("pull_request_url", pullRequest.html_url); - core.setOutput("branch_name", branchName); - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - await core.summary - .addRaw( - ` - ## Pull Request - - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - - **Branch**: \`${branchName}\` - - **Base Branch**: \`${baseBranch}\` - ` - ) - .write(); - } catch (prError) { - core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); - core.info("Falling back to creating an issue instead"); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). - **Original error:** ${prError instanceof Error ? prError.message : String(prError)} - You can manually create a pull request from the branch if needed.${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - await core.summary - .addRaw( - ` - ## Fallback Issue Created - - **Issue**: [#${issue.number}](${issue.html_url}) - - **Branch**: [\`${branchName}\`](${branchUrl}) - - **Base Branch**: \`${baseBranch}\` - - **Note**: Pull request creation failed, created issue as fallback - ` - ) - .write(); - } catch (issueError) { - core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`); - return; - } + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); diff --git a/.github/workflows/deep-report.lock.yml b/.github/workflows/deep-report.lock.yml index bcefc2f1cd..ff6fec0acc 100644 --- a/.github/workflows/deep-report.lock.yml +++ b/.github/workflows/deep-report.lock.yml @@ -6973,8 +6973,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6987,1313 +6995,178 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "DeepReport - Intelligence Gathering Agent" - GH_AW_TRACKER_ID: "deep-report-intel-agent" - GH_AW_ENGINE_ID: "codex" - with: + GH_AW_DISCUSSION_CATEGORY: "reports" +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; } } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); - } - (async () => { await main(); })(); - - name: Upload Assets - id: upload_assets - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'upload_asset')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "DeepReport - Intelligence Gathering Agent" - GH_AW_TRACKER_ID: "deep-report-intel-agent" - GH_AW_ENGINE_ID: "codex" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const path = require("path"); - const crypto = require("crypto"); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function normalizeBranchName(branchName) { - if (!branchName || typeof branchName !== "string" || branchName.trim() === "") { - return branchName; - } - let normalized = branchName.replace(/[^a-zA-Z0-9\-_/.]+/g, "-"); - normalized = normalized.replace(/-+/g, "-"); - normalized = normalized.replace(/^-+|-+$/g, ""); - if (normalized.length > 128) { - normalized = normalized.substring(0, 128); - } - normalized = normalized.replace(/-+$/, ""); - normalized = normalized.toLowerCase(); - return normalized; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const branchName = process.env.GH_AW_ASSETS_BRANCH; - if (!branchName || typeof branchName !== "string") { - core.setFailed("GH_AW_ASSETS_BRANCH environment variable is required but not set"); - return; - } - const normalizedBranchName = normalizeBranchName(branchName); - core.info(`Using assets branch: ${normalizedBranchName}`); - const result = loadAgentOutput(); - if (!result.success) { - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - const uploadItems = result.items.filter( item => item.type === "upload_assets"); - const uploadAssetItems = result.items.filter( item => item.type === "upload_asset"); - const allUploadItems = [...uploadItems, ...uploadAssetItems]; - if (allUploadItems.length === 0) { - core.info("No upload-asset items found in agent output"); - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - core.info(`Found ${allUploadItems.length} upload-asset item(s)`); - let uploadCount = 0; - let hasChanges = false; + (async () => { try { - try { - await exec.exec(`git rev-parse --verify origin/${normalizedBranchName}`); - await exec.exec(`git checkout -B ${normalizedBranchName} origin/${normalizedBranchName}`); - core.info(`Checked out existing branch from origin: ${normalizedBranchName}`); - } catch (originError) { - if (!normalizedBranchName.startsWith("assets/")) { - core.setFailed( - `Branch '${normalizedBranchName}' does not start with the required 'assets/' prefix. ` + - `Orphaned branches can only be automatically created under the 'assets/' prefix. ` + - `Please create the branch manually first, or use a branch name starting with 'assets/'.` - ); - return; - } - core.info(`Creating new orphaned branch: ${normalizedBranchName}`); - await exec.exec(`git checkout --orphan ${normalizedBranchName}`); - await exec.exec(`git rm -rf .`); - await exec.exec(`git clean -fdx`); - } - for (const asset of uploadAssetItems) { - try { - const { fileName, sha, size, targetFileName } = asset; - if (!fileName || !sha || !targetFileName) { - core.error(`Invalid asset entry missing required fields: ${JSON.stringify(asset)}`); - continue; - } - const assetSourcePath = path.join("/tmp/gh-aw/safeoutputs/assets", fileName); - if (!fs.existsSync(assetSourcePath)) { - core.warning(`Asset file not found: ${assetSourcePath}`); - continue; - } - const fileContent = fs.readFileSync(assetSourcePath); - const computedSha = crypto.createHash("sha256").update(fileContent).digest("hex"); - if (computedSha !== sha) { - core.warning(`SHA mismatch for ${fileName}: expected ${sha}, got ${computedSha}`); - continue; - } - if (fs.existsSync(targetFileName)) { - core.info(`Asset ${targetFileName} already exists, skipping`); - continue; - } - fs.copyFileSync(assetSourcePath, targetFileName); - await exec.exec(`git add "${targetFileName}"`); - uploadCount++; - hasChanges = true; - core.info(`Added asset: ${targetFileName} (${size} bytes)`); - } catch (error) { - core.warning(`Failed to process asset ${asset.fileName}: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (hasChanges) { - const commitMessage = `[skip-ci] Add ${uploadCount} asset(s)`; - await exec.exec(`git`, [`commit`, `-m`, commitMessage]); - if (isStaged) { - core.summary.addRaw("## Staged Asset Publication"); - } else { - await exec.exec(`git push origin ${normalizedBranchName}`); - core.summary.addRaw("## Assets").addRaw(`Successfully uploaded **${uploadCount}** assets to branch \`${normalizedBranchName}\``).addRaw(""); - core.info(`Successfully uploaded ${uploadCount} assets to branch ${normalizedBranchName}`); - } - for (const asset of uploadAssetItems) { - if (asset.fileName && asset.sha && asset.size && asset.url) { - core.summary.addRaw(`- [\`${asset.fileName}\`](${asset.url}) → \`${asset.targetFileName}\` (${asset.size} bytes)`); - } - } - core.summary.write(); - } else { - core.info("No new assets to upload"); - } + await main(); } catch (error) { - core.setFailed(`Failed to upload assets: ${error instanceof Error ? error.message : String(error)}`); - return; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - core.setOutput("upload_count", uploadCount.toString()); - core.setOutput("branch_name", normalizedBranchName); - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/dependabot-go-checker.lock.yml b/.github/workflows/dependabot-go-checker.lock.yml index b333438bb0..f098d66126 100644 --- a/.github/workflows/dependabot-go-checker.lock.yml +++ b/.github/workflows/dependabot-go-checker.lock.yml @@ -6953,9 +6953,16 @@ jobs: issues: write timeout-minutes: 15 outputs: - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6968,1428 +6975,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_entity_helpers.cjs << 'EOF_96ffce00' - // @ts-check - /// - - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - - /** - * @typedef {'issue' | 'pull_request'} EntityType - */ - - /** - * @typedef {Object} EntityConfig - * @property {EntityType} entityType - The type of entity (issue or pull_request) - * @property {string} itemType - The agent output item type (e.g., "close_issue") - * @property {string} itemTypeDisplay - Human-readable item type for log messages (e.g., "close-issue") - * @property {string} numberField - The field name for the entity number in agent output (e.g., "issue_number") - * @property {string} envVarPrefix - Environment variable prefix (e.g., "GH_AW_CLOSE_ISSUE") - * @property {string[]} contextEvents - GitHub event names for this entity context - * @property {string} contextPayloadField - The field name in context.payload (e.g., "issue") - * @property {string} urlPath - URL path segment (e.g., "issues" or "pull") - * @property {string} displayName - Human-readable display name (e.g., "issue" or "pull request") - * @property {string} displayNamePlural - Human-readable display name plural (e.g., "issues" or "pull requests") - * @property {string} displayNameCapitalized - Capitalized display name (e.g., "Issue" or "Pull Request") - * @property {string} displayNameCapitalizedPlural - Capitalized display name plural (e.g., "Issues" or "Pull Requests") - */ - - /** - * @typedef {Object} EntityCallbacks - * @property {(github: any, owner: string, repo: string, entityNumber: number) => Promise<{number: number, title: string, labels: Array<{name: string}>, html_url: string, state: string}>} getDetails - * @property {(github: any, owner: string, repo: string, entityNumber: number, message: string) => Promise<{id: number, html_url: string}>} addComment - * @property {(github: any, owner: string, repo: string, entityNumber: number) => Promise<{number: number, html_url: string, title: string}>} closeEntity - */ - - /** - * Build the run URL for the current workflow - * @returns {string} The workflow run URL - */ - function buildRunUrl() { - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - } - - /** - * Build comment body with tracker ID and footer - * @param {string} body - The original comment body - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - PR number that triggered this workflow - * @returns {string} The complete comment body with tracker ID and footer - */ - function buildCommentBody(body, triggeringIssueNumber, triggeringPRNumber) { - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runUrl = buildRunUrl(); - - let commentBody = body.trim(); - commentBody += getTrackerID("markdown"); - commentBody += generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, undefined); - - return commentBody; - } - - /** - * Check if labels match the required labels filter - * @param {Array<{name: string}>} entityLabels - Labels on the entity - * @param {string[]} requiredLabels - Required labels (any match) - * @returns {boolean} True if entity has at least one required label - */ - function checkLabelFilter(entityLabels, requiredLabels) { - if (requiredLabels.length === 0) { - return true; - } - const labelNames = entityLabels.map(l => l.name); - return requiredLabels.some(required => labelNames.includes(required)); - } - - /** - * Check if title matches the required prefix filter - * @param {string} title - Entity title - * @param {string} requiredTitlePrefix - Required title prefix - * @returns {boolean} True if title starts with required prefix - */ - function checkTitlePrefixFilter(title, requiredTitlePrefix) { - if (!requiredTitlePrefix) { - return true; - } - return title.startsWith(requiredTitlePrefix); - } - - /** - * Generate staged preview content for a close entity operation - * @param {EntityConfig} config - Entity configuration - * @param {any[]} items - Items to preview - * @param {string[]} requiredLabels - Required labels filter - * @param {string} requiredTitlePrefix - Required title prefix filter - * @returns {Promise} - */ - async function generateCloseEntityStagedPreview(config, items, requiredLabels, requiredTitlePrefix) { - let summaryContent = `## 🎭 Staged Mode: Close ${config.displayNameCapitalizedPlural} Preview\n\n`; - summaryContent += `The following ${config.displayNamePlural} would be closed if staged mode was disabled:\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += `### ${config.displayNameCapitalized} ${i + 1}\n`; - - const entityNumber = item[config.numberField]; - if (entityNumber) { - const repoUrl = getRepositoryUrl(); - const entityUrl = `${repoUrl}/${config.urlPath}/${entityNumber}`; - summaryContent += `**Target ${config.displayNameCapitalized}:** [#${entityNumber}](${entityUrl})\n\n`; - } else { - summaryContent += `**Target:** Current ${config.displayName}\n\n`; - } - - summaryContent += `**Comment:**\n${item.body || "No content provided"}\n\n`; - - if (requiredLabels.length > 0) { - summaryContent += `**Required Labels:** ${requiredLabels.join(", ")}\n\n`; - } - if (requiredTitlePrefix) { - summaryContent += `**Required Title Prefix:** ${requiredTitlePrefix}\n\n`; - } - - summaryContent += "---\n\n"; - } - - // Write to step summary - await core.summary.addRaw(summaryContent).write(); - core.info(`📝 ${config.displayNameCapitalized} close preview written to step summary`); - } - - /** - * Parse configuration from environment variables - * @param {string} envVarPrefix - Environment variable prefix - * @returns {{requiredLabels: string[], requiredTitlePrefix: string, target: string}} - */ - function parseEntityConfig(envVarPrefix) { - const labelsEnvVar = `${envVarPrefix}_REQUIRED_LABELS`; - const titlePrefixEnvVar = `${envVarPrefix}_REQUIRED_TITLE_PREFIX`; - const targetEnvVar = `${envVarPrefix}_TARGET`; - - const requiredLabels = process.env[labelsEnvVar] ? process.env[labelsEnvVar].split(",").map(l => l.trim()) : []; - const requiredTitlePrefix = process.env[titlePrefixEnvVar] || ""; - const target = process.env[targetEnvVar] || "triggering"; - - return { requiredLabels, requiredTitlePrefix, target }; - } - - /** - * Resolve the entity number based on target configuration and context - * @param {EntityConfig} config - Entity configuration - * @param {string} target - Target configuration ("triggering", "*", or explicit number) - * @param {any} item - The agent output item - * @param {boolean} isEntityContext - Whether we're in the correct entity context - * @returns {{success: true, number: number} | {success: false, message: string}} - */ - function resolveEntityNumber(config, target, item, isEntityContext) { - if (target === "*") { - const targetNumber = item[config.numberField]; - if (targetNumber) { - const parsed = parseInt(targetNumber, 10); - if (isNaN(parsed) || parsed <= 0) { - return { - success: false, - message: `Invalid ${config.displayName} number specified: ${targetNumber}`, - }; - } - return { success: true, number: parsed }; - } - return { - success: false, - message: `Target is "*" but no ${config.numberField} specified in ${config.itemTypeDisplay} item`, - }; - } - - if (target !== "triggering") { - const parsed = parseInt(target, 10); - if (isNaN(parsed) || parsed <= 0) { - return { - success: false, - message: `Invalid ${config.displayName} number in target configuration: ${target}`, - }; - } - return { success: true, number: parsed }; - } - - // Default behavior: use triggering entity - if (isEntityContext) { - const number = context.payload[config.contextPayloadField]?.number; - if (!number) { - return { - success: false, - message: `${config.displayNameCapitalized} context detected but no ${config.displayName} found in payload`, - }; - } - return { success: true, number }; - } - - return { - success: false, - message: `Not in ${config.displayName} context and no explicit target specified`, - }; - } - - /** - * Escape special markdown characters in a title - * @param {string} title - The title to escape - * @returns {string} Escaped title - */ - function escapeMarkdownTitle(title) { - return title.replace(/[[\]()]/g, "\\$&"); - } - - /** - * Process close entity items from agent output - * @param {EntityConfig} config - Entity configuration - * @param {EntityCallbacks} callbacks - Entity-specific API callbacks - * @returns {Promise|undefined>} - */ - async function processCloseEntityItems(config, callbacks) { - // Check if we're in staged mode - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - - const result = loadAgentOutput(); - if (!result.success) { - return; - } - - // Find all items of this type - const items = result.items.filter(/** @param {any} item */ item => item.type === config.itemType); - if (items.length === 0) { - core.info(`No ${config.itemTypeDisplay} items found in agent output`); - return; - } - - core.info(`Found ${items.length} ${config.itemTypeDisplay} item(s)`); - - // Get configuration from environment - const { requiredLabels, requiredTitlePrefix, target } = parseEntityConfig(config.envVarPrefix); - - core.info(`Configuration: requiredLabels=${requiredLabels.join(",")}, requiredTitlePrefix=${requiredTitlePrefix}, target=${target}`); - - // Check if we're in the correct entity context - const isEntityContext = config.contextEvents.some(event => context.eventName === event); - - // If in staged mode, emit step summary instead of closing entities - if (isStaged) { - await generateCloseEntityStagedPreview(config, items, requiredLabels, requiredTitlePrefix); - return; - } - - // Validate context based on target configuration - if (target === "triggering" && !isEntityContext) { - core.info(`Target is "triggering" but not running in ${config.displayName} context, skipping ${config.displayName} close`); - return; - } - - // Extract triggering context for footer generation - const triggeringIssueNumber = context.payload?.issue?.number; - const triggeringPRNumber = context.payload?.pull_request?.number; - - const closedEntities = []; - - // Process each item - for (let i = 0; i < items.length; i++) { - const item = items[i]; - core.info(`Processing ${config.itemTypeDisplay} item ${i + 1}/${items.length}: bodyLength=${item.body.length}`); - - // Resolve entity number - const resolved = resolveEntityNumber(config, target, item, isEntityContext); - if (!resolved.success) { - core.info(resolved.message); - continue; - } - const entityNumber = resolved.number; - - try { - // Fetch entity details to check filters - const entity = await callbacks.getDetails(github, context.repo.owner, context.repo.repo, entityNumber); - - // Apply label filter - if (!checkLabelFilter(entity.labels, requiredLabels)) { - core.info(`${config.displayNameCapitalized} #${entityNumber} does not have required labels: ${requiredLabels.join(", ")}`); - continue; - } - - // Apply title prefix filter - if (!checkTitlePrefixFilter(entity.title, requiredTitlePrefix)) { - core.info(`${config.displayNameCapitalized} #${entityNumber} does not have required title prefix: ${requiredTitlePrefix}`); - continue; - } - - // Check if already closed - if (entity.state === "closed") { - core.info(`${config.displayNameCapitalized} #${entityNumber} is already closed, skipping`); - continue; - } - - // Build comment body - const commentBody = buildCommentBody(item.body, triggeringIssueNumber, triggeringPRNumber); - - // Add comment before closing - const comment = await callbacks.addComment(github, context.repo.owner, context.repo.repo, entityNumber, commentBody); - core.info(`✓ Added comment to ${config.displayName} #${entityNumber}: ${comment.html_url}`); - - // Close the entity - const closedEntity = await callbacks.closeEntity(github, context.repo.owner, context.repo.repo, entityNumber); - core.info(`✓ Closed ${config.displayName} #${entityNumber}: ${closedEntity.html_url}`); - - closedEntities.push({ - entity: closedEntity, - comment, - }); - - // Set outputs for the last closed entity (for backward compatibility) - if (i === items.length - 1) { - const numberOutputName = config.entityType === "issue" ? "issue_number" : "pull_request_number"; - const urlOutputName = config.entityType === "issue" ? "issue_url" : "pull_request_url"; - core.setOutput(numberOutputName, closedEntity.number); - core.setOutput(urlOutputName, closedEntity.html_url); - core.setOutput("comment_url", comment.html_url); - } - } catch (error) { - core.error(`✗ Failed to close ${config.displayName} #${entityNumber}: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } - } - - // Write summary for all closed entities - if (closedEntities.length > 0) { - let summaryContent = `\n\n## Closed ${config.displayNameCapitalizedPlural}\n`; - for (const { entity, comment } of closedEntities) { - const escapedTitle = escapeMarkdownTitle(entity.title); - summaryContent += `- ${config.displayNameCapitalized} #${entity.number}: [${escapedTitle}](${entity.html_url}) ([comment](${comment.html_url}))\n`; - } - await core.summary.addRaw(summaryContent).write(); - } - - core.info(`Successfully closed ${closedEntities.length} ${config.displayName}(s)`); - return closedEntities; - } - - /** - * Configuration for closing issues - * @type {EntityConfig} - */ - const ISSUE_CONFIG = { - entityType: "issue", - itemType: "close_issue", - itemTypeDisplay: "close-issue", - numberField: "issue_number", - envVarPrefix: "GH_AW_CLOSE_ISSUE", - contextEvents: ["issues", "issue_comment"], - contextPayloadField: "issue", - urlPath: "issues", - displayName: "issue", - displayNamePlural: "issues", - displayNameCapitalized: "Issue", - displayNameCapitalizedPlural: "Issues", - }; - - /** - * Configuration for closing pull requests - * @type {EntityConfig} - */ - const PULL_REQUEST_CONFIG = { - entityType: "pull_request", - itemType: "close_pull_request", - itemTypeDisplay: "close-pull-request", - numberField: "pull_request_number", - envVarPrefix: "GH_AW_CLOSE_PR", - contextEvents: ["pull_request", "pull_request_review_comment"], - contextPayloadField: "pull_request", - urlPath: "pull", - displayName: "pull request", - displayNamePlural: "pull requests", - displayNameCapitalized: "Pull Request", - displayNameCapitalizedPlural: "Pull Requests", - }; - - module.exports = { - processCloseEntityItems, - generateCloseEntityStagedPreview, - checkLabelFilter, - checkTitlePrefixFilter, - parseEntityConfig, - resolveEntityNumber, - buildCommentBody, - escapeMarkdownTitle, - ISSUE_CONFIG, - PULL_REQUEST_CONFIG, - }; - - EOF_96ffce00 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_ISSUE_TITLE_PREFIX: "[deps]" GH_AW_ISSUE_LABELS: "dependencies,go" - GH_AW_WORKFLOW_NAME: "Dependabot Dependency Checker" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; + summaryContent += `**Repository:** ${item.repo}\n\n`; } if (item.body) { - content += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } - return content; - }, - }); + summaryContent += "---\n\n"; + } + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } - } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; - } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; } await core.summary.addRaw(summaryContent).write(); } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } (async () => { - await main(); - })(); - - name: Close Issue - id: close_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'close_issue')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Dependabot Dependency Checker" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { processCloseEntityItems, ISSUE_CONFIG } = require('/tmp/gh-aw/scripts/close_entity_helpers.cjs'); - async function getIssueDetails(github, owner, repo, issueNumber) { - const { data: issue } = await github.rest.issues.get({ - owner, - repo, - issue_number: issueNumber, - }); - if (!issue) { - throw new Error(`Issue #${issueNumber} not found in ${owner}/${repo}`); + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - return issue; - } - async function addIssueComment(github, owner, repo, issueNumber, message) { - const { data: comment } = await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body: message, - }); - return comment; - } - async function closeIssue(github, owner, repo, issueNumber) { - const { data: issue } = await github.rest.issues.update({ - owner, - repo, - issue_number: issueNumber, - state: "closed", - }); - return issue; - } - async function main() { - return processCloseEntityItems(ISSUE_CONFIG, { - getDetails: getIssueDetails, - addComment: addIssueComment, - closeEntity: closeIssue, - }); - } - (async () => { await main(); })(); + })(); diff --git a/.github/workflows/dev-hawk.lock.yml b/.github/workflows/dev-hawk.lock.yml index 229ee5323c..492c8e4158 100644 --- a/.github/workflows/dev-hawk.lock.yml +++ b/.github/workflows/dev-hawk.lock.yml @@ -6778,8 +6778,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6792,1020 +6800,175 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_COMMENT_TARGET: "*" - GH_AW_WORKFLOW_NAME: "Dev Hawk" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🦅 *Observed from above by [{workflow_name}]({run_url})*\",\"runStarted\":\"🦅 Dev Hawk circles the sky! [{workflow_name}]({run_url}) is monitoring this {event_type} from above...\",\"runSuccess\":\"🦅 Hawk eyes report! [{workflow_name}]({run_url}) has completed reconnaissance. Intel delivered! 🎯\",\"runFailure\":\"🦅 Hawk down! [{workflow_name}]({run_url}) {status}. The skies grow quiet...\"}" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } - } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } + summaryContent += "---\n\n"; } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); - } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 0e6ddb0bd7..5c887a20e5 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -7501,8 +7501,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7515,770 +7523,175 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Create Pull Request - id: create_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Dev" - GH_AW_ENGINE_ID: "copilot" - with: +GH_AW_PR_ALLOW_EMPTY=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { - core.setOutput("pull_request_number", ""); - core.setOutput("pull_request_url", ""); core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); - core.setOutput("branch_name", ""); - core.setOutput("fallback_used", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const workflowId = process.env.GH_AW_WORKFLOW_ID; - if (!workflowId) { - throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); - } - const baseBranch = process.env.GH_AW_BASE_BRANCH; - if (!baseBranch) { - throw new Error("GH_AW_BASE_BRANCH environment variable is required"); - } - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - let outputContent = ""; - if (agentOutputFile.trim() !== "") { - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - } - const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn"; - const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - let patchContent = ""; - let isEmpty = true; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } - if (patchContent.includes("Failed to generate patch")) { - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return; - } - throw new Error(message); - } - core.info("Patch size validation passed"); - } - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to push - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); return; } - core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - if (!title) { - title = "Agent Output"; - } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - const labelsEnv = process.env.GH_AW_PR_LABELS; - const labels = labelsEnv - ? labelsEnv - .split(",") - .map( label => label.trim()) - .filter( label => label) - : []; - const draftEnv = process.env.GH_AW_PR_DRAFT; - const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); - const randomHex = crypto.randomBytes(8).toString("hex"); - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); } else { - branchName = `${branchName}-${randomHex}`; - core.info(`Using branch name from JSONL with added salt: ${branchName}`); - } - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); - core.info(`Fetching latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${baseBranch}`); - core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`); - await exec.exec(`git checkout -b ${branchName}`); - core.info(`Created new branch from base: ${branchName}`); - if (!isEmpty) { - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - try { - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - try { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Changes pushed to branch"); - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - > [!NOTE] - > This was originally intended as a pull request, but the git push operation failed. - > - > **Workflow Run:** [View run details and download patch artifact](${runUrl}) - > - > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above. - To apply the patch locally: - \`\`\`sh - # Download the artifact from the workflow run ${runUrl} - # (Use GitHub MCP tools if gh CLI is not available) - gh run download ${runId} -n aw.patch - # Apply the patch - git am aw.patch - \`\`\` - ${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - core.setOutput("push_failed", "true"); - await core.summary - .addRaw( - ` - ## Push Failure Fallback - - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} - - **Fallback Issue:** [#${issue.number}](${issue.html_url}) - - **Patch Artifact:** Available in workflow run artifacts - - **Note:** Push failed, created issue as fallback - ` - ) - .write(); - return; - } catch (issueError) { - core.setFailed( - `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` - ); - return; } } - } else { - core.info("Skipping patch application (empty patch)"); - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - try { - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Empty branch pushed successfully"); - } catch (pushError) { - core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - return; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - } else { - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to apply - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } + await core.summary.addRaw(summaryContent).write(); } + core.info(`Completed processing ${result.items.length} safe output item(s)`); + } + (async () => { try { - const { data: pullRequest } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }); - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - labels: labels, - }); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } - core.setOutput("pull_request_number", pullRequest.number); - core.setOutput("pull_request_url", pullRequest.html_url); - core.setOutput("branch_name", branchName); - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - await core.summary - .addRaw( - ` - ## Pull Request - - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - - **Branch**: \`${branchName}\` - - **Base Branch**: \`${baseBranch}\` - ` - ) - .write(); - } catch (prError) { - core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); - core.info("Falling back to creating an issue instead"); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). - **Original error:** ${prError instanceof Error ? prError.message : String(prError)} - You can manually create a pull request from the branch if needed.${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - await core.summary - .addRaw( - ` - ## Fallback Issue Created - - **Issue**: [#${issue.number}](${issue.html_url}) - - **Branch**: [\`${branchName}\`](${branchUrl}) - - **Base Branch**: \`${baseBranch}\` - - **Note**: Pull request creation failed, created issue as fallback - ` - ) - .write(); - } catch (issueError) { - core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`); - return; - } + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); diff --git a/.github/workflows/developer-docs-consolidator.lock.yml b/.github/workflows/developer-docs-consolidator.lock.yml index 5324cc1df3..9ad0fe6841 100644 --- a/.github/workflows/developer-docs-consolidator.lock.yml +++ b/.github/workflows/developer-docs-consolidator.lock.yml @@ -6940,10 +6940,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} - create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6956,1831 +6962,180 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Developer Documentation Consolidator" - GH_AW_ENGINE_ID: "claude" - with: + GH_AW_DISCUSSION_CATEGORY: "General" +GH_AW_CLOSE_OLDER_DISCUSSIONS=true GH_AW_PR_LABELS: "documentation,automation" + GH_AW_PR_TITLE_PREFIX: "[docs] " +GH_AW_PR_DRAFT=false with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; - } - summaryContent += "---\n\n"; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); - return; - } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; - } - } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } - } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; - } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } } await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); + return; } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); - } - (async () => { await main(); })(); - - name: Create Pull Request - id: create_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PR_TITLE_PREFIX: "[docs] " - GH_AW_PR_LABELS: "documentation,automation" - GH_AW_PR_DRAFT: "false" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Developer Documentation Consolidator" - GH_AW_ENGINE_ID: "claude" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; - } - async function main() { - core.setOutput("pull_request_number", ""); - core.setOutput("pull_request_url", ""); - core.setOutput("issue_number", ""); - core.setOutput("issue_url", ""); - core.setOutput("branch_name", ""); - core.setOutput("fallback_used", ""); - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const workflowId = process.env.GH_AW_WORKFLOW_ID; - if (!workflowId) { - throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); - } - const baseBranch = process.env.GH_AW_BASE_BRANCH; - if (!baseBranch) { - throw new Error("GH_AW_BASE_BRANCH environment variable is required"); - } - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - let outputContent = ""; - if (agentOutputFile.trim() !== "") { - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn"; - const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; + } } } } - let patchContent = ""; - let isEmpty = true; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } - if (patchContent.includes("Failed to generate patch")) { - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - throw new Error(message); } - core.info("Patch size validation passed"); - } - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to push - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); - return; - } - core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); - return; - } - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - if (!title) { - title = "Agent Output"; - } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - const labelsEnv = process.env.GH_AW_PR_LABELS; - const labels = labelsEnv - ? labelsEnv - .split(",") - .map( label => label.trim()) - .filter( label => label) - : []; - const draftEnv = process.env.GH_AW_PR_DRAFT; - const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); - const randomHex = crypto.randomBytes(8).toString("hex"); - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; - } else { - branchName = `${branchName}-${randomHex}`; - core.info(`Using branch name from JSONL with added salt: ${branchName}`); - } - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); - core.info(`Fetching latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${baseBranch}`); - core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`); - await exec.exec(`git checkout -b ${branchName}`); - core.info(`Created new branch from base: ${branchName}`); - if (!isEmpty) { - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - try { - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - try { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Changes pushed to branch"); - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - > [!NOTE] - > This was originally intended as a pull request, but the git push operation failed. - > - > **Workflow Run:** [View run details and download patch artifact](${runUrl}) - > - > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above. - To apply the patch locally: - \`\`\`sh - # Download the artifact from the workflow run ${runUrl} - # (Use GitHub MCP tools if gh CLI is not available) - gh run download ${runId} -n aw.patch - # Apply the patch - git am aw.patch - \`\`\` - ${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - core.setOutput("push_failed", "true"); - await core.summary - .addRaw( - ` - ## Push Failure Fallback - - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} - - **Fallback Issue:** [#${issue.number}](${issue.html_url}) - - **Patch Artifact:** Available in workflow run artifacts - - **Note:** Push failed, created issue as fallback - ` - ) - .write(); - return; - } catch (issueError) { - core.setFailed( - `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` - ); - return; - } - } - } else { - core.info("Skipping patch application (empty patch)"); - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - try { - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Empty branch pushed successfully"); - } catch (pushError) { - core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - return; - } - } else { - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to apply - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } } + core.info(`Completed processing ${result.items.length} safe output item(s)`); + } + (async () => { try { - const { data: pullRequest } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }); - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - labels: labels, - }); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } - core.setOutput("pull_request_number", pullRequest.number); - core.setOutput("pull_request_url", pullRequest.html_url); - core.setOutput("branch_name", branchName); - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - await core.summary - .addRaw( - ` - ## Pull Request - - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - - **Branch**: \`${branchName}\` - - **Base Branch**: \`${baseBranch}\` - ` - ) - .write(); - } catch (prError) { - core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); - core.info("Falling back to creating an issue instead"); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). - **Original error:** ${prError instanceof Error ? prError.message : String(prError)} - You can manually create a pull request from the branch if needed.${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - await core.summary - .addRaw( - ` - ## Fallback Issue Created - - **Issue**: [#${issue.number}](${issue.html_url}) - - **Branch**: [\`${branchName}\`](${branchUrl}) - - **Base Branch**: \`${baseBranch}\` - - **Note**: Pull request creation failed, created issue as fallback - ` - ) - .write(); - } catch (issueError) { - core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`); - return; - } + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/dictation-prompt.lock.yml b/.github/workflows/dictation-prompt.lock.yml index ecbefd5471..adc62527ae 100644 --- a/.github/workflows/dictation-prompt.lock.yml +++ b/.github/workflows/dictation-prompt.lock.yml @@ -6610,8 +6610,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6624,773 +6632,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Create Pull Request - id: create_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PR_TITLE_PREFIX: "[docs] " GH_AW_PR_LABELS: "documentation,automation" - GH_AW_PR_DRAFT: "false" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Dictation Prompt Generator" - GH_AW_ENGINE_ID: "copilot" - with: + GH_AW_PR_TITLE_PREFIX: "[docs] " +GH_AW_PR_DRAFT=false with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { - core.setOutput("pull_request_number", ""); - core.setOutput("pull_request_url", ""); core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); - core.setOutput("branch_name", ""); - core.setOutput("fallback_used", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const workflowId = process.env.GH_AW_WORKFLOW_ID; - if (!workflowId) { - throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); - } - const baseBranch = process.env.GH_AW_BASE_BRANCH; - if (!baseBranch) { - throw new Error("GH_AW_BASE_BRANCH environment variable is required"); - } - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - let outputContent = ""; - if (agentOutputFile.trim() !== "") { - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - } - const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn"; - const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - let patchContent = ""; - let isEmpty = true; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } - if (patchContent.includes("Failed to generate patch")) { - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return; - } - throw new Error(message); - } - core.info("Patch size validation passed"); - } - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to push - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); return; } - core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - if (!title) { - title = "Agent Output"; - } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - const labelsEnv = process.env.GH_AW_PR_LABELS; - const labels = labelsEnv - ? labelsEnv - .split(",") - .map( label => label.trim()) - .filter( label => label) - : []; - const draftEnv = process.env.GH_AW_PR_DRAFT; - const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); - const randomHex = crypto.randomBytes(8).toString("hex"); - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); } else { - branchName = `${branchName}-${randomHex}`; - core.info(`Using branch name from JSONL with added salt: ${branchName}`); - } - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); - core.info(`Fetching latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${baseBranch}`); - core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`); - await exec.exec(`git checkout -b ${branchName}`); - core.info(`Created new branch from base: ${branchName}`); - if (!isEmpty) { - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - try { - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - try { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Changes pushed to branch"); - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - > [!NOTE] - > This was originally intended as a pull request, but the git push operation failed. - > - > **Workflow Run:** [View run details and download patch artifact](${runUrl}) - > - > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above. - To apply the patch locally: - \`\`\`sh - # Download the artifact from the workflow run ${runUrl} - # (Use GitHub MCP tools if gh CLI is not available) - gh run download ${runId} -n aw.patch - # Apply the patch - git am aw.patch - \`\`\` - ${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - core.setOutput("push_failed", "true"); - await core.summary - .addRaw( - ` - ## Push Failure Fallback - - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} - - **Fallback Issue:** [#${issue.number}](${issue.html_url}) - - **Patch Artifact:** Available in workflow run artifacts - - **Note:** Push failed, created issue as fallback - ` - ) - .write(); - return; - } catch (issueError) { - core.setFailed( - `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` - ); - return; } } - } else { - core.info("Skipping patch application (empty patch)"); - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - try { - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Empty branch pushed successfully"); - } catch (pushError) { - core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - return; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - } else { - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to apply - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } + await core.summary.addRaw(summaryContent).write(); } + core.info(`Completed processing ${result.items.length} safe output item(s)`); + } + (async () => { try { - const { data: pullRequest } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }); - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - labels: labels, - }); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } - core.setOutput("pull_request_number", pullRequest.number); - core.setOutput("pull_request_url", pullRequest.html_url); - core.setOutput("branch_name", branchName); - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - await core.summary - .addRaw( - ` - ## Pull Request - - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - - **Branch**: \`${branchName}\` - - **Base Branch**: \`${baseBranch}\` - ` - ) - .write(); - } catch (prError) { - core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); - core.info("Falling back to creating an issue instead"); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). - **Original error:** ${prError instanceof Error ? prError.message : String(prError)} - You can manually create a pull request from the branch if needed.${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - await core.summary - .addRaw( - ` - ## Fallback Issue Created - - **Issue**: [#${issue.number}](${issue.html_url}) - - **Branch**: [\`${branchName}\`](${branchUrl}) - - **Base Branch**: \`${baseBranch}\` - - **Note**: Pull request creation failed, created issue as fallback - ` - ) - .write(); - } catch (issueError) { - core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`); - return; - } + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); diff --git a/.github/workflows/docs-noob-tester.lock.yml b/.github/workflows/docs-noob-tester.lock.yml index 340fe0dae1..0a115b10dd 100644 --- a/.github/workflows/docs-noob-tester.lock.yml +++ b/.github/workflows/docs-noob-tester.lock.yml @@ -6703,8 +6703,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6717,1309 +6725,176 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Documentation Noob Tester" - GH_AW_ENGINE_ID: "copilot" - with: + GH_AW_DISCUSSION_CATEGORY: "General" +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; } } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); - - name: Upload Assets - id: upload_assets - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'upload_asset')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Documentation Noob Tester" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const path = require("path"); - const crypto = require("crypto"); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function normalizeBranchName(branchName) { - if (!branchName || typeof branchName !== "string" || branchName.trim() === "") { - return branchName; - } - let normalized = branchName.replace(/[^a-zA-Z0-9\-_/.]+/g, "-"); - normalized = normalized.replace(/-+/g, "-"); - normalized = normalized.replace(/^-+|-+$/g, ""); - if (normalized.length > 128) { - normalized = normalized.substring(0, 128); - } - normalized = normalized.replace(/-+$/, ""); - normalized = normalized.toLowerCase(); - return normalized; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const branchName = process.env.GH_AW_ASSETS_BRANCH; - if (!branchName || typeof branchName !== "string") { - core.setFailed("GH_AW_ASSETS_BRANCH environment variable is required but not set"); - return; - } - const normalizedBranchName = normalizeBranchName(branchName); - core.info(`Using assets branch: ${normalizedBranchName}`); - const result = loadAgentOutput(); - if (!result.success) { - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - const uploadItems = result.items.filter( item => item.type === "upload_assets"); - const uploadAssetItems = result.items.filter( item => item.type === "upload_asset"); - const allUploadItems = [...uploadItems, ...uploadAssetItems]; - if (allUploadItems.length === 0) { - core.info("No upload-asset items found in agent output"); - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - core.info(`Found ${allUploadItems.length} upload-asset item(s)`); - let uploadCount = 0; - let hasChanges = false; + (async () => { try { - try { - await exec.exec(`git rev-parse --verify origin/${normalizedBranchName}`); - await exec.exec(`git checkout -B ${normalizedBranchName} origin/${normalizedBranchName}`); - core.info(`Checked out existing branch from origin: ${normalizedBranchName}`); - } catch (originError) { - if (!normalizedBranchName.startsWith("assets/")) { - core.setFailed( - `Branch '${normalizedBranchName}' does not start with the required 'assets/' prefix. ` + - `Orphaned branches can only be automatically created under the 'assets/' prefix. ` + - `Please create the branch manually first, or use a branch name starting with 'assets/'.` - ); - return; - } - core.info(`Creating new orphaned branch: ${normalizedBranchName}`); - await exec.exec(`git checkout --orphan ${normalizedBranchName}`); - await exec.exec(`git rm -rf .`); - await exec.exec(`git clean -fdx`); - } - for (const asset of uploadAssetItems) { - try { - const { fileName, sha, size, targetFileName } = asset; - if (!fileName || !sha || !targetFileName) { - core.error(`Invalid asset entry missing required fields: ${JSON.stringify(asset)}`); - continue; - } - const assetSourcePath = path.join("/tmp/gh-aw/safeoutputs/assets", fileName); - if (!fs.existsSync(assetSourcePath)) { - core.warning(`Asset file not found: ${assetSourcePath}`); - continue; - } - const fileContent = fs.readFileSync(assetSourcePath); - const computedSha = crypto.createHash("sha256").update(fileContent).digest("hex"); - if (computedSha !== sha) { - core.warning(`SHA mismatch for ${fileName}: expected ${sha}, got ${computedSha}`); - continue; - } - if (fs.existsSync(targetFileName)) { - core.info(`Asset ${targetFileName} already exists, skipping`); - continue; - } - fs.copyFileSync(assetSourcePath, targetFileName); - await exec.exec(`git add "${targetFileName}"`); - uploadCount++; - hasChanges = true; - core.info(`Added asset: ${targetFileName} (${size} bytes)`); - } catch (error) { - core.warning(`Failed to process asset ${asset.fileName}: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (hasChanges) { - const commitMessage = `[skip-ci] Add ${uploadCount} asset(s)`; - await exec.exec(`git`, [`commit`, `-m`, commitMessage]); - if (isStaged) { - core.summary.addRaw("## Staged Asset Publication"); - } else { - await exec.exec(`git push origin ${normalizedBranchName}`); - core.summary.addRaw("## Assets").addRaw(`Successfully uploaded **${uploadCount}** assets to branch \`${normalizedBranchName}\``).addRaw(""); - core.info(`Successfully uploaded ${uploadCount} assets to branch ${normalizedBranchName}`); - } - for (const asset of uploadAssetItems) { - if (asset.fileName && asset.sha && asset.size && asset.url) { - core.summary.addRaw(`- [\`${asset.fileName}\`](${asset.url}) → \`${asset.targetFileName}\` (${asset.size} bytes)`); - } - } - core.summary.write(); - } else { - core.info("No new assets to upload"); - } + await main(); } catch (error) { - core.setFailed(`Failed to upload assets: ${error instanceof Error ? error.message : String(error)}`); - return; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - core.setOutput("upload_count", uploadCount.toString()); - core.setOutput("branch_name", normalizedBranchName); - } - (async () => { await main(); })(); + })(); diff --git a/.github/workflows/duplicate-code-detector.lock.yml b/.github/workflows/duplicate-code-detector.lock.yml index fc2470c993..8971b974ab 100644 --- a/.github/workflows/duplicate-code-detector.lock.yml +++ b/.github/workflows/duplicate-code-detector.lock.yml @@ -6339,9 +6339,16 @@ jobs: issues: write timeout-minutes: 15 outputs: - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6354,944 +6361,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_ISSUE_TITLE_PREFIX: "[duplicate-code] " GH_AW_ISSUE_LABELS: "code-quality,automated-analysis" - GH_AW_WORKFLOW_NAME: "Duplicate Code Detector" - GH_AW_ENGINE_ID: "codex" - with: +GH_AW_ASSIGN_COPILOT=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; + summaryContent += `**Repository:** ${item.repo}\n\n`; } if (item.body) { - content += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } - return content; - }, - }); + summaryContent += "---\n\n"; + } + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } - } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; - } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; } await core.summary.addRaw(summaryContent).write(); } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } (async () => { - await main(); + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } })(); diff --git a/.github/workflows/example-workflow-analyzer.lock.yml b/.github/workflows/example-workflow-analyzer.lock.yml index 00cfcbaa74..e3b40e2de7 100644 --- a/.github/workflows/example-workflow-analyzer.lock.yml +++ b/.github/workflows/example-workflow-analyzer.lock.yml @@ -6135,8 +6135,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6149,1171 +6157,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Weekly Workflow Analysis" - GH_AW_ENGINE_ID: "claude" - with: + GH_AW_DISCUSSION_CATEGORY: "Audits" + GH_AW_DISCUSSION_TITLE_PREFIX: "[workflow-analysis] " +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; } } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); diff --git a/.github/workflows/github-mcp-structural-analysis.lock.yml b/.github/workflows/github-mcp-structural-analysis.lock.yml index b82c2ae5bc..df40fa97b8 100644 --- a/.github/workflows/github-mcp-structural-analysis.lock.yml +++ b/.github/workflows/github-mcp-structural-analysis.lock.yml @@ -6877,8 +6877,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6891,1311 +6899,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "GitHub MCP Structural Analysis" - GH_AW_ENGINE_ID: "claude" - with: + GH_AW_DISCUSSION_CATEGORY: "audits" + GH_AW_DISCUSSION_TITLE_PREFIX: "[mcp-analysis] " +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; } } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); - - name: Upload Assets - id: upload_assets - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'upload_asset')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "GitHub MCP Structural Analysis" - GH_AW_ENGINE_ID: "claude" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const path = require("path"); - const crypto = require("crypto"); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function normalizeBranchName(branchName) { - if (!branchName || typeof branchName !== "string" || branchName.trim() === "") { - return branchName; - } - let normalized = branchName.replace(/[^a-zA-Z0-9\-_/.]+/g, "-"); - normalized = normalized.replace(/-+/g, "-"); - normalized = normalized.replace(/^-+|-+$/g, ""); - if (normalized.length > 128) { - normalized = normalized.substring(0, 128); - } - normalized = normalized.replace(/-+$/, ""); - normalized = normalized.toLowerCase(); - return normalized; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const branchName = process.env.GH_AW_ASSETS_BRANCH; - if (!branchName || typeof branchName !== "string") { - core.setFailed("GH_AW_ASSETS_BRANCH environment variable is required but not set"); - return; - } - const normalizedBranchName = normalizeBranchName(branchName); - core.info(`Using assets branch: ${normalizedBranchName}`); - const result = loadAgentOutput(); - if (!result.success) { - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - const uploadItems = result.items.filter( item => item.type === "upload_assets"); - const uploadAssetItems = result.items.filter( item => item.type === "upload_asset"); - const allUploadItems = [...uploadItems, ...uploadAssetItems]; - if (allUploadItems.length === 0) { - core.info("No upload-asset items found in agent output"); - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - core.info(`Found ${allUploadItems.length} upload-asset item(s)`); - let uploadCount = 0; - let hasChanges = false; + (async () => { try { - try { - await exec.exec(`git rev-parse --verify origin/${normalizedBranchName}`); - await exec.exec(`git checkout -B ${normalizedBranchName} origin/${normalizedBranchName}`); - core.info(`Checked out existing branch from origin: ${normalizedBranchName}`); - } catch (originError) { - if (!normalizedBranchName.startsWith("assets/")) { - core.setFailed( - `Branch '${normalizedBranchName}' does not start with the required 'assets/' prefix. ` + - `Orphaned branches can only be automatically created under the 'assets/' prefix. ` + - `Please create the branch manually first, or use a branch name starting with 'assets/'.` - ); - return; - } - core.info(`Creating new orphaned branch: ${normalizedBranchName}`); - await exec.exec(`git checkout --orphan ${normalizedBranchName}`); - await exec.exec(`git rm -rf .`); - await exec.exec(`git clean -fdx`); - } - for (const asset of uploadAssetItems) { - try { - const { fileName, sha, size, targetFileName } = asset; - if (!fileName || !sha || !targetFileName) { - core.error(`Invalid asset entry missing required fields: ${JSON.stringify(asset)}`); - continue; - } - const assetSourcePath = path.join("/tmp/gh-aw/safeoutputs/assets", fileName); - if (!fs.existsSync(assetSourcePath)) { - core.warning(`Asset file not found: ${assetSourcePath}`); - continue; - } - const fileContent = fs.readFileSync(assetSourcePath); - const computedSha = crypto.createHash("sha256").update(fileContent).digest("hex"); - if (computedSha !== sha) { - core.warning(`SHA mismatch for ${fileName}: expected ${sha}, got ${computedSha}`); - continue; - } - if (fs.existsSync(targetFileName)) { - core.info(`Asset ${targetFileName} already exists, skipping`); - continue; - } - fs.copyFileSync(assetSourcePath, targetFileName); - await exec.exec(`git add "${targetFileName}"`); - uploadCount++; - hasChanges = true; - core.info(`Added asset: ${targetFileName} (${size} bytes)`); - } catch (error) { - core.warning(`Failed to process asset ${asset.fileName}: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (hasChanges) { - const commitMessage = `[skip-ci] Add ${uploadCount} asset(s)`; - await exec.exec(`git`, [`commit`, `-m`, commitMessage]); - if (isStaged) { - core.summary.addRaw("## Staged Asset Publication"); - } else { - await exec.exec(`git push origin ${normalizedBranchName}`); - core.summary.addRaw("## Assets").addRaw(`Successfully uploaded **${uploadCount}** assets to branch \`${normalizedBranchName}\``).addRaw(""); - core.info(`Successfully uploaded ${uploadCount} assets to branch ${normalizedBranchName}`); - } - for (const asset of uploadAssetItems) { - if (asset.fileName && asset.sha && asset.size && asset.url) { - core.summary.addRaw(`- [\`${asset.fileName}\`](${asset.url}) → \`${asset.targetFileName}\` (${asset.size} bytes)`); - } - } - core.summary.write(); - } else { - core.info("No new assets to upload"); - } + await main(); } catch (error) { - core.setFailed(`Failed to upload assets: ${error instanceof Error ? error.message : String(error)}`); - return; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - core.setOutput("upload_count", uploadCount.toString()); - core.setOutput("branch_name", normalizedBranchName); - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/github-mcp-tools-report.lock.yml b/.github/workflows/github-mcp-tools-report.lock.yml index 156702ffad..221ee56f3b 100644 --- a/.github/workflows/github-mcp-tools-report.lock.yml +++ b/.github/workflows/github-mcp-tools-report.lock.yml @@ -6752,10 +6752,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} - create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6768,1831 +6774,180 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "GitHub MCP Remote Server Tools Report Generator" - GH_AW_ENGINE_ID: "claude" - with: + GH_AW_DISCUSSION_CATEGORY: "audits" +GH_AW_CLOSE_OLDER_DISCUSSIONS=true GH_AW_PR_LABELS: "documentation,automation" + GH_AW_PR_TITLE_PREFIX: "[mcp-tools] " +GH_AW_PR_DRAFT=false with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; - } - summaryContent += "---\n\n"; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); - return; - } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; - } - } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } - } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; - } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } } await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); + return; } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); - } - (async () => { await main(); })(); - - name: Create Pull Request - id: create_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PR_TITLE_PREFIX: "[mcp-tools] " - GH_AW_PR_LABELS: "documentation,automation" - GH_AW_PR_DRAFT: "false" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "GitHub MCP Remote Server Tools Report Generator" - GH_AW_ENGINE_ID: "claude" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; - } - async function main() { - core.setOutput("pull_request_number", ""); - core.setOutput("pull_request_url", ""); - core.setOutput("issue_number", ""); - core.setOutput("issue_url", ""); - core.setOutput("branch_name", ""); - core.setOutput("fallback_used", ""); - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const workflowId = process.env.GH_AW_WORKFLOW_ID; - if (!workflowId) { - throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); - } - const baseBranch = process.env.GH_AW_BASE_BRANCH; - if (!baseBranch) { - throw new Error("GH_AW_BASE_BRANCH environment variable is required"); - } - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - let outputContent = ""; - if (agentOutputFile.trim() !== "") { - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn"; - const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; + } } } } - let patchContent = ""; - let isEmpty = true; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } - if (patchContent.includes("Failed to generate patch")) { - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - throw new Error(message); } - core.info("Patch size validation passed"); - } - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to push - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); - return; - } - core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); - return; - } - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - if (!title) { - title = "Agent Output"; - } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - const labelsEnv = process.env.GH_AW_PR_LABELS; - const labels = labelsEnv - ? labelsEnv - .split(",") - .map( label => label.trim()) - .filter( label => label) - : []; - const draftEnv = process.env.GH_AW_PR_DRAFT; - const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); - const randomHex = crypto.randomBytes(8).toString("hex"); - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; - } else { - branchName = `${branchName}-${randomHex}`; - core.info(`Using branch name from JSONL with added salt: ${branchName}`); - } - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); - core.info(`Fetching latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${baseBranch}`); - core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`); - await exec.exec(`git checkout -b ${branchName}`); - core.info(`Created new branch from base: ${branchName}`); - if (!isEmpty) { - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - try { - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - try { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Changes pushed to branch"); - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - > [!NOTE] - > This was originally intended as a pull request, but the git push operation failed. - > - > **Workflow Run:** [View run details and download patch artifact](${runUrl}) - > - > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above. - To apply the patch locally: - \`\`\`sh - # Download the artifact from the workflow run ${runUrl} - # (Use GitHub MCP tools if gh CLI is not available) - gh run download ${runId} -n aw.patch - # Apply the patch - git am aw.patch - \`\`\` - ${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - core.setOutput("push_failed", "true"); - await core.summary - .addRaw( - ` - ## Push Failure Fallback - - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} - - **Fallback Issue:** [#${issue.number}](${issue.html_url}) - - **Patch Artifact:** Available in workflow run artifacts - - **Note:** Push failed, created issue as fallback - ` - ) - .write(); - return; - } catch (issueError) { - core.setFailed( - `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` - ); - return; - } - } - } else { - core.info("Skipping patch application (empty patch)"); - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - try { - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Empty branch pushed successfully"); - } catch (pushError) { - core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - return; - } - } else { - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to apply - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } } + core.info(`Completed processing ${result.items.length} safe output item(s)`); + } + (async () => { try { - const { data: pullRequest } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }); - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - labels: labels, - }); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } - core.setOutput("pull_request_number", pullRequest.number); - core.setOutput("pull_request_url", pullRequest.html_url); - core.setOutput("branch_name", branchName); - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - await core.summary - .addRaw( - ` - ## Pull Request - - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - - **Branch**: \`${branchName}\` - - **Base Branch**: \`${baseBranch}\` - ` - ) - .write(); - } catch (prError) { - core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); - core.info("Falling back to creating an issue instead"); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). - **Original error:** ${prError instanceof Error ? prError.message : String(prError)} - You can manually create a pull request from the branch if needed.${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - await core.summary - .addRaw( - ` - ## Fallback Issue Created - - **Issue**: [#${issue.number}](${issue.html_url}) - - **Branch**: [\`${branchName}\`](${branchUrl}) - - **Base Branch**: \`${baseBranch}\` - - **Note**: Pull request creation failed, created issue as fallback - ` - ) - .write(); - } catch (issueError) { - core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`); - return; - } + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/glossary-maintainer.lock.yml b/.github/workflows/glossary-maintainer.lock.yml index 363c9a232b..5e4f433da9 100644 --- a/.github/workflows/glossary-maintainer.lock.yml +++ b/.github/workflows/glossary-maintainer.lock.yml @@ -7237,8 +7237,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7251,775 +7259,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Create Pull Request - id: create_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PR_TITLE_PREFIX: "[docs] " GH_AW_PR_LABELS: "documentation,glossary" - GH_AW_PR_DRAFT: "false" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Glossary Maintainer" - GH_AW_ENGINE_ID: "copilot" - with: + GH_AW_PR_TITLE_PREFIX: "[docs] " +GH_AW_PR_DRAFT=false with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { - core.setOutput("pull_request_number", ""); - core.setOutput("pull_request_url", ""); core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); - core.setOutput("branch_name", ""); - core.setOutput("fallback_used", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const workflowId = process.env.GH_AW_WORKFLOW_ID; - if (!workflowId) { - throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); - } - const baseBranch = process.env.GH_AW_BASE_BRANCH; - if (!baseBranch) { - throw new Error("GH_AW_BASE_BRANCH environment variable is required"); - } - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - let outputContent = ""; - if (agentOutputFile.trim() !== "") { - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - } - const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn"; - const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - let patchContent = ""; - let isEmpty = true; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } - if (patchContent.includes("Failed to generate patch")) { - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return; - } - throw new Error(message); - } - core.info("Patch size validation passed"); - } - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to push - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); return; } - core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - if (!title) { - title = "Agent Output"; - } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - const labelsEnv = process.env.GH_AW_PR_LABELS; - const labels = labelsEnv - ? labelsEnv - .split(",") - .map( label => label.trim()) - .filter( label => label) - : []; - const draftEnv = process.env.GH_AW_PR_DRAFT; - const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); - const randomHex = crypto.randomBytes(8).toString("hex"); - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); } else { - branchName = `${branchName}-${randomHex}`; - core.info(`Using branch name from JSONL with added salt: ${branchName}`); - } - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); - core.info(`Fetching latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${baseBranch}`); - core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`); - await exec.exec(`git checkout -b ${branchName}`); - core.info(`Created new branch from base: ${branchName}`); - if (!isEmpty) { - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - try { - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - try { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Changes pushed to branch"); - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - > [!NOTE] - > This was originally intended as a pull request, but the git push operation failed. - > - > **Workflow Run:** [View run details and download patch artifact](${runUrl}) - > - > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above. - To apply the patch locally: - \`\`\`sh - # Download the artifact from the workflow run ${runUrl} - # (Use GitHub MCP tools if gh CLI is not available) - gh run download ${runId} -n aw.patch - # Apply the patch - git am aw.patch - \`\`\` - ${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - core.setOutput("push_failed", "true"); - await core.summary - .addRaw( - ` - ## Push Failure Fallback - - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} - - **Fallback Issue:** [#${issue.number}](${issue.html_url}) - - **Patch Artifact:** Available in workflow run artifacts - - **Note:** Push failed, created issue as fallback - ` - ) - .write(); - return; - } catch (issueError) { - core.setFailed( - `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` - ); - return; } } - } else { - core.info("Skipping patch application (empty patch)"); - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - try { - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Empty branch pushed successfully"); - } catch (pushError) { - core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - return; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - } else { - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to apply - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } + await core.summary.addRaw(summaryContent).write(); } + core.info(`Completed processing ${result.items.length} safe output item(s)`); + } + (async () => { try { - const { data: pullRequest } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }); - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - labels: labels, - }); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } - core.setOutput("pull_request_number", pullRequest.number); - core.setOutput("pull_request_url", pullRequest.html_url); - core.setOutput("branch_name", branchName); - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - await core.summary - .addRaw( - ` - ## Pull Request - - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - - **Branch**: \`${branchName}\` - - **Base Branch**: \`${baseBranch}\` - ` - ) - .write(); - } catch (prError) { - core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); - core.info("Falling back to creating an issue instead"); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). - **Original error:** ${prError instanceof Error ? prError.message : String(prError)} - You can manually create a pull request from the branch if needed.${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - await core.summary - .addRaw( - ` - ## Fallback Issue Created - - **Issue**: [#${issue.number}](${issue.html_url}) - - **Branch**: [\`${branchName}\`](${branchUrl}) - - **Base Branch**: \`${baseBranch}\` - - **Note**: Pull request creation failed, created issue as fallback - ` - ) - .write(); - } catch (issueError) { - core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`); - return; - } + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/go-fan.lock.yml b/.github/workflows/go-fan.lock.yml index 10f0532dd1..ae4bac6181 100644 --- a/.github/workflows/go-fan.lock.yml +++ b/.github/workflows/go-fan.lock.yml @@ -6531,8 +6531,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6545,1174 +6553,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Go Fan" - GH_AW_TRACKER_ID: "go-fan-daily" - GH_AW_ENGINE_ID: "claude" - with: + GH_AW_DISCUSSION_CATEGORY: "General" + GH_AW_DISCUSSION_TITLE_PREFIX: "[go-fan] " +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; - } - } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); } } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); update_cache_memory: needs: diff --git a/.github/workflows/go-file-size-reduction-project64.campaign.g.lock.yml b/.github/workflows/go-file-size-reduction-project64.campaign.g.lock.yml index 569d59bf5c..efc62ace04 100644 --- a/.github/workflows/go-file-size-reduction-project64.campaign.g.lock.yml +++ b/.github/workflows/go-file-size-reduction-project64.campaign.g.lock.yml @@ -6546,8 +6546,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6560,1360 +6568,175 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Go File Size Reduction Campaign (Project 64)" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } - } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } + summaryContent += "---\n\n"; } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; - } - (async () => { await main(); })(); - - name: Update Project - id: update_project - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'update_project')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Go File Size Reduction Campaign (Project 64)" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function logGraphQLError(error, operation) { - (core.error(`GraphQL Error during: ${operation}`), core.error(`Message: ${error.message}`)); - const errorList = Array.isArray(error.errors) ? error.errors : [], - hasInsufficientScopes = errorList.some(e => e && "INSUFFICIENT_SCOPES" === e.type), - hasNotFound = errorList.some(e => e && "NOT_FOUND" === e.type); - (hasInsufficientScopes - ? core.error( - "This looks like a token permission problem for Projects v2. The GraphQL fields used by update_project require a token with Projects access (classic PAT: scope 'project'; fine-grained PAT: Organization permission 'Projects' and access to the org). Fix: set safe-outputs.update-project.github-token to a secret PAT that can access the target org project." - ) - : hasNotFound && - /projectV2\b/.test(error.message) && - core.error( - "GitHub returned NOT_FOUND for ProjectV2. This can mean either: (1) the project number is wrong for Projects v2, (2) the project is a classic Projects board (not Projects v2), or (3) the token does not have access to that org/user project." - ), - error.errors && - (core.error(`Errors array (${error.errors.length} error(s)):`), - error.errors.forEach((err, idx) => { - (core.error(` [${idx + 1}] ${err.message}`), - err.type && core.error(` Type: ${err.type}`), - err.path && core.error(` Path: ${JSON.stringify(err.path)}`), - err.locations && core.error(` Locations: ${JSON.stringify(err.locations)}`)); - })), - error.request && core.error(`Request: ${JSON.stringify(error.request, null, 2)}`), - error.data && core.error(`Response data: ${JSON.stringify(error.data, null, 2)}`)); - } - function parseProjectInput(projectUrl) { - if (!projectUrl || "string" != typeof projectUrl) throw new Error(`Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`); - const urlMatch = projectUrl.match(/github\.com\/(?:users|orgs)\/[^/]+\/projects\/(\d+)/); - if (!urlMatch) throw new Error(`Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`); - return urlMatch[1]; - } - function parseProjectUrl(projectUrl) { - if (!projectUrl || "string" != typeof projectUrl) throw new Error(`Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`); - const match = projectUrl.match(/github\.com\/(users|orgs)\/([^/]+)\/projects\/(\d+)/); - if (!match) throw new Error(`Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`); - return { scope: match[1], ownerLogin: match[2], projectNumber: match[3] }; - } - async function listAccessibleProjectsV2(projectInfo) { - const baseQuery = - "projectsV2(first: 100) {\n totalCount\n nodes {\n id\n number\n title\n closed\n url\n }\n edges {\n node {\n id\n number\n title\n closed\n url\n }\n }\n }"; - if ("orgs" === projectInfo.scope) { - const result = await github.graphql(`query($login: String!) {\n organization(login: $login) {\n ${baseQuery}\n }\n }`, { login: projectInfo.ownerLogin }), - conn = result && result.organization && result.organization.projectsV2, - rawNodes = conn && Array.isArray(conn.nodes) ? conn.nodes : [], - rawEdges = conn && Array.isArray(conn.edges) ? conn.edges : [], - nodeNodes = rawNodes.filter(Boolean), - edgeNodes = rawEdges.map(e => e && e.node).filter(Boolean), - unique = new Map(); - for (const n of [...nodeNodes, ...edgeNodes]) n && "string" == typeof n.id && unique.set(n.id, n); - return { - nodes: Array.from(unique.values()), - totalCount: conn && conn.totalCount, - diagnostics: { rawNodesCount: rawNodes.length, nullNodesCount: rawNodes.length - nodeNodes.length, rawEdgesCount: rawEdges.length, nullEdgeNodesCount: rawEdges.filter(e => !e || !e.node).length }, - }; - } - const result = await github.graphql(`query($login: String!) {\n user(login: $login) {\n ${baseQuery}\n }\n }`, { login: projectInfo.ownerLogin }), - conn = result && result.user && result.user.projectsV2, - rawNodes = conn && Array.isArray(conn.nodes) ? conn.nodes : [], - rawEdges = conn && Array.isArray(conn.edges) ? conn.edges : [], - nodeNodes = rawNodes.filter(Boolean), - edgeNodes = rawEdges.map(e => e && e.node).filter(Boolean), - unique = new Map(); - for (const n of [...nodeNodes, ...edgeNodes]) n && "string" == typeof n.id && unique.set(n.id, n); - return { - nodes: Array.from(unique.values()), - totalCount: conn && conn.totalCount, - diagnostics: { rawNodesCount: rawNodes.length, nullNodesCount: rawNodes.length - nodeNodes.length, rawEdgesCount: rawEdges.length, nullEdgeNodesCount: rawEdges.filter(e => !e || !e.node).length }, - }; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - function summarizeProjectsV2(projects, limit = 20) { - if (!Array.isArray(projects) || 0 === projects.length) return "(none)"; - const normalized = projects - .filter(p => p && "number" == typeof p.number && "string" == typeof p.title) - .slice(0, limit) - .map(p => `#${p.number} ${p.closed ? "(closed) " : ""}${p.title}`); - return normalized.length > 0 ? normalized.join("; ") : "(none)"; - } - function summarizeEmptyProjectsV2List(list) { - const total = "number" == typeof list.totalCount ? list.totalCount : void 0, - d = list && list.diagnostics, - diag = d ? ` nodes=${d.rawNodesCount} (null=${d.nullNodesCount}), edges=${d.rawEdgesCount} (nullNode=${d.nullEdgeNodesCount})` : ""; - return "number" == typeof total && total > 0 - ? `(none; totalCount=${total} but returned 0 readable project nodes${diag}. This often indicates the token can see the org/user but lacks Projects v2 access, or the org enforces SSO and the token is not authorized.)` - : `(none${diag})`; - } - async function resolveProjectV2(projectInfo, projectNumberInt) { + (async () => { try { - if ("orgs" === projectInfo.scope) { - const direct = await github.graphql( - "query($login: String!, $number: Int!) {\n organization(login: $login) {\n projectV2(number: $number) {\n id\n number\n title\n url\n }\n }\n }", - { login: projectInfo.ownerLogin, number: projectNumberInt } - ), - project = direct && direct.organization && direct.organization.projectV2; - if (project) return project; - } else { - const direct = await github.graphql( - "query($login: String!, $number: Int!) {\n user(login: $login) {\n projectV2(number: $number) {\n id\n number\n title\n url\n }\n }\n }", - { login: projectInfo.ownerLogin, number: projectNumberInt } - ), - project = direct && direct.user && direct.user.projectV2; - if (project) return project; - } - } catch (error) { - core.warning(`Direct projectV2(number) query failed; falling back to projectsV2 list search: ${error.message}`); - } - const list = await listAccessibleProjectsV2(projectInfo), - nodes = Array.isArray(list.nodes) ? list.nodes : [], - found = nodes.find(p => p && "number" == typeof p.number && p.number === projectNumberInt); - if (found) return found; - const summary = nodes.length > 0 ? summarizeProjectsV2(nodes) : summarizeEmptyProjectsV2List(list), - total = "number" == typeof list.totalCount ? ` (totalCount=${list.totalCount})` : "", - who = "orgs" === projectInfo.scope ? `org ${projectInfo.ownerLogin}` : `user ${projectInfo.ownerLogin}`; - throw new Error(`Project #${projectNumberInt} not found or not accessible for ${who}.${total} Accessible Projects v2: ${summary}`); - } - function generateCampaignId(projectUrl, projectNumber) { - const urlMatch = projectUrl.match(/github\.com\/(users|orgs)\/([^/]+)\/projects/); - return `${`${urlMatch ? urlMatch[2] : "project"}-project-${projectNumber}` - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .substring(0, 30)}-${Date.now().toString(36).substring(0, 8)}`; - } - async function updateProject(output) { - const { owner, repo } = context.repo, - projectInfo = parseProjectUrl(output.project), - projectNumberFromUrl = projectInfo.projectNumber, - campaignId = output.campaign_id || generateCampaignId(output.project, projectNumberFromUrl); - try { - let repoResult; - (core.info(`Looking up project #${projectNumberFromUrl} from URL: ${output.project}`), core.info("[1/5] Fetching repository information...")); - try { - repoResult = await github.graphql( - "query($owner: String!, $repo: String!) {\n repository(owner: $owner, name: $repo) {\n id\n owner {\n id\n __typename\n }\n }\n }", - { owner, repo } - ); - } catch (error) { - throw (logGraphQLError(error, "Fetching repository information"), error); - } - const repositoryId = repoResult.repository.id, - ownerType = repoResult.repository.owner.__typename; - core.info(`✓ Repository: ${owner}/${repo} (${ownerType})`); - try { - const viewerResult = await github.graphql("query {\n viewer {\n login\n }\n }"); - viewerResult && viewerResult.viewer && viewerResult.viewer.login && core.info(`✓ Authenticated as: ${viewerResult.viewer.login}`); - } catch (viewerError) { - core.warning(`Could not resolve token identity (viewer.login): ${viewerError.message}`); - } - let projectId; - core.info(`[2/5] Resolving project from URL (scope=${projectInfo.scope}, login=${projectInfo.ownerLogin}, number=${projectNumberFromUrl})...`); - let resolvedProjectNumber = projectNumberFromUrl; - try { - const projectNumberInt = parseInt(projectNumberFromUrl, 10); - if (!Number.isFinite(projectNumberInt)) throw new Error(`Invalid project number parsed from URL: ${projectNumberFromUrl}`); - const project = await resolveProjectV2(projectInfo, projectNumberInt); - ((projectId = project.id), (resolvedProjectNumber = String(project.number)), core.info(`✓ Resolved project #${resolvedProjectNumber} (${projectInfo.ownerLogin}) (ID: ${projectId})`)); - } catch (error) { - throw (logGraphQLError(error, "Resolving project from URL"), error); - } - core.info("[3/5] Linking project to repository..."); - try { - await github.graphql( - "mutation($projectId: ID!, $repositoryId: ID!) {\n linkProjectV2ToRepository(input: {\n projectId: $projectId,\n repositoryId: $repositoryId\n }) {\n repository {\n id\n }\n }\n }", - { projectId, repositoryId } - ); - } catch (linkError) { - (linkError.message && linkError.message.includes("already linked")) || (logGraphQLError(linkError, "Linking project to repository"), core.warning(`Could not link project: ${linkError.message}`)); - } - (core.info("✓ Project linked to repository"), core.info("[4/5] Processing content (issue/PR) if specified...")); - const hasContentNumber = void 0 !== output.content_number && null !== output.content_number, - hasIssue = void 0 !== output.issue && null !== output.issue, - hasPullRequest = void 0 !== output.pull_request && null !== output.pull_request, - values = []; - if ( - (hasContentNumber && values.push({ key: "content_number", value: output.content_number }), - hasIssue && values.push({ key: "issue", value: output.issue }), - hasPullRequest && values.push({ key: "pull_request", value: output.pull_request }), - values.length > 1) - ) { - const uniqueValues = [...new Set(values.map(v => String(v.value)))], - list = values.map(v => `${v.key}=${v.value}`).join(", "), - descriptor = uniqueValues.length > 1 ? "different values" : `same value "${uniqueValues[0]}"`; - core.warning(`Multiple content number fields (${descriptor}): ${list}. Using priority content_number > issue > pull_request.`); - } - (hasIssue && core.warning('Field "issue" deprecated; use "content_number" instead.'), hasPullRequest && core.warning('Field "pull_request" deprecated; use "content_number" instead.')); - let contentNumber = null; - if (hasContentNumber || hasIssue || hasPullRequest) { - const rawContentNumber = hasContentNumber ? output.content_number : hasIssue ? output.issue : output.pull_request, - sanitizedContentNumber = null == rawContentNumber ? "" : "number" == typeof rawContentNumber ? rawContentNumber.toString() : String(rawContentNumber).trim(); - if (sanitizedContentNumber) { - if (!/^\d+$/.test(sanitizedContentNumber)) throw new Error(`Invalid content number "${rawContentNumber}". Provide a positive integer.`); - contentNumber = Number.parseInt(sanitizedContentNumber, 10); - } else core.warning("Content number field provided but empty; skipping project item update."); - } - if (null !== contentNumber) { - const contentType = "pull_request" === output.content_type ? "PullRequest" : "issue" === output.content_type || output.issue ? "Issue" : "PullRequest", - contentQuery = - "Issue" === contentType - ? "query($owner: String!, $repo: String!, $number: Int!) {\n repository(owner: $owner, name: $repo) {\n issue(number: $number) {\n id\n }\n }\n }" - : "query($owner: String!, $repo: String!, $number: Int!) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $number) {\n id\n }\n }\n }", - contentResult = await github.graphql(contentQuery, { owner, repo, number: contentNumber }), - contentId = "Issue" === contentType ? contentResult.repository.issue.id : contentResult.repository.pullRequest.id, - existingItem = await (async function (projectId, contentId) { - let hasNextPage = !0, - endCursor = null; - for (; hasNextPage; ) { - const result = await github.graphql( - "query($projectId: ID!, $after: String) {\n node(id: $projectId) {\n ... on ProjectV2 {\n items(first: 100, after: $after) {\n nodes {\n id\n content {\n ... on Issue {\n id\n }\n ... on PullRequest {\n id\n }\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n }\n }", - { projectId, after: endCursor } - ), - found = result.node.items.nodes.find(item => item.content && item.content.id === contentId); - if (found) return found; - ((hasNextPage = result.node.items.pageInfo.hasNextPage), (endCursor = result.node.items.pageInfo.endCursor)); - } - return null; - })(projectId, contentId); - let itemId; - if (existingItem) ((itemId = existingItem.id), core.info("✓ Item already on board")); - else { - itemId = ( - await github.graphql( - "mutation($projectId: ID!, $contentId: ID!) {\n addProjectV2ItemById(input: {\n projectId: $projectId,\n contentId: $contentId\n }) {\n item {\n id\n }\n }\n }", - { projectId, contentId } - ) - ).addProjectV2ItemById.item.id; - try { - await github.rest.issues.addLabels({ owner, repo, issue_number: contentNumber, labels: [`campaign:${campaignId}`] }); - } catch (labelError) { - core.warning(`Failed to add campaign label: ${labelError.message}`); - } - } - if (output.fields && Object.keys(output.fields).length > 0) { - const projectFields = ( - await github.graphql( - "query($projectId: ID!) {\n node(id: $projectId) {\n ... on ProjectV2 {\n fields(first: 20) {\n nodes {\n ... on ProjectV2Field {\n id\n name\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n options {\n id\n name\n color\n }\n }\n }\n }\n }\n }\n }", - { projectId } - ) - ).node.fields.nodes; - for (const [fieldName, fieldValue] of Object.entries(output.fields)) { - const normalizedFieldName = fieldName - .split(/[\s_-]+/) - .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(" "); - let valueToSet, - field = projectFields.find(f => f.name.toLowerCase() === normalizedFieldName.toLowerCase()); - if (!field) - if ("classification" === fieldName.toLowerCase() || ("string" == typeof fieldValue && fieldValue.includes("|"))) - try { - field = ( - await github.graphql( - "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType\n }) {\n projectV2Field {\n ... on ProjectV2Field {\n id\n name\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n }\n }\n }", - { projectId, name: normalizedFieldName, dataType: "TEXT" } - ) - ).createProjectV2Field.projectV2Field; - } catch (createError) { - core.warning(`Failed to create field "${fieldName}": ${createError.message}`); - continue; - } - else - try { - field = ( - await github.graphql( - "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType,\n singleSelectOptions: $options\n }) {\n projectV2Field {\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n ... on ProjectV2Field {\n id\n name\n }\n }\n }\n }", - { projectId, name: normalizedFieldName, dataType: "SINGLE_SELECT", options: [{ name: String(fieldValue), description: "", color: "GRAY" }] } - ) - ).createProjectV2Field.projectV2Field; - } catch (createError) { - core.warning(`Failed to create field "${fieldName}": ${createError.message}`); - continue; - } - if (field.options) { - let option = field.options.find(o => o.name === fieldValue); - if (!option) - try { - const allOptions = [...field.options.map(o => ({ name: o.name, description: "", color: o.color || "GRAY" })), { name: String(fieldValue), description: "", color: "GRAY" }], - updatedField = ( - await github.graphql( - "mutation($fieldId: ID!, $fieldName: String!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {\n updateProjectV2Field(input: {\n fieldId: $fieldId,\n name: $fieldName,\n singleSelectOptions: $options\n }) {\n projectV2Field {\n ... on ProjectV2SingleSelectField {\n id\n options {\n id\n name\n }\n }\n }\n }\n }", - { fieldId: field.id, fieldName: field.name, options: allOptions } - ) - ).updateProjectV2Field.projectV2Field; - ((option = updatedField.options.find(o => o.name === fieldValue)), (field = updatedField)); - } catch (createError) { - core.warning(`Failed to create option "${fieldValue}": ${createError.message}`); - continue; - } - if (!option) { - core.warning(`Could not get option ID for "${fieldValue}" in field "${fieldName}"`); - continue; - } - valueToSet = { singleSelectOptionId: option.id }; - } else valueToSet = { text: String(fieldValue) }; - await github.graphql( - "mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) {\n updateProjectV2ItemFieldValue(input: {\n projectId: $projectId,\n itemId: $itemId,\n fieldId: $fieldId,\n value: $value\n }) {\n projectV2Item {\n id\n }\n }\n }", - { projectId, itemId, fieldId: field.id, value: valueToSet } - ); - } - } - core.setOutput("item-id", itemId); - } + await main(); } catch (error) { - if (error.message && error.message.includes("does not have permission to create projects")) { - const usingCustomToken = !!process.env.GH_AW_PROJECT_GITHUB_TOKEN; - core.error( - `Failed to manage project: ${error.message}\n\nTroubleshooting:\n • Create the project manually at https://github.com/orgs/${owner}/projects/new.\n • Or supply a PAT (classic with project + repo scopes, or fine-grained with Projects: Read+Write) via GH_AW_PROJECT_GITHUB_TOKEN.\n • Or use a GitHub App with Projects: Read+Write permission.\n • Ensure the workflow grants projects: write.\n\n` + - (usingCustomToken ? "GH_AW_PROJECT_GITHUB_TOKEN is set but lacks access." : "Using default GITHUB_TOKEN - this cannot access Projects v2 API. You must configure GH_AW_PROJECT_GITHUB_TOKEN.") - ); - } else core.error(`Failed to manage project: ${error.message}`); - throw error; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - async function main() { - const result = loadAgentOutput(); - if (!result.success) return; - const updateProjectItems = result.items.filter(item => "update_project" === item.type); - if (0 !== updateProjectItems.length) - for (let i = 0; i < updateProjectItems.length; i++) { - const output = updateProjectItems[i]; - try { - await updateProject(output); - } catch (error) { - (core.error(`Failed to process item ${i + 1}`), logGraphQLError(error, `Processing update_project item ${i + 1}`)); - } - } - } - ("undefined" != typeof module && module.exports && (module.exports = { updateProject, parseProjectInput, generateCampaignId, main }), ("undefined" != typeof module && require.main !== module) || main()); + })(); diff --git a/.github/workflows/go-file-size-reduction.campaign.g.lock.yml b/.github/workflows/go-file-size-reduction.campaign.g.lock.yml index a4bae2032c..872f75c7d4 100644 --- a/.github/workflows/go-file-size-reduction.campaign.g.lock.yml +++ b/.github/workflows/go-file-size-reduction.campaign.g.lock.yml @@ -6546,8 +6546,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6560,1360 +6568,175 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Go File Size Reduction Campaign" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } - } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } + summaryContent += "---\n\n"; } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; - } - (async () => { await main(); })(); - - name: Update Project - id: update_project - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'update_project')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Go File Size Reduction Campaign" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function logGraphQLError(error, operation) { - (core.error(`GraphQL Error during: ${operation}`), core.error(`Message: ${error.message}`)); - const errorList = Array.isArray(error.errors) ? error.errors : [], - hasInsufficientScopes = errorList.some(e => e && "INSUFFICIENT_SCOPES" === e.type), - hasNotFound = errorList.some(e => e && "NOT_FOUND" === e.type); - (hasInsufficientScopes - ? core.error( - "This looks like a token permission problem for Projects v2. The GraphQL fields used by update_project require a token with Projects access (classic PAT: scope 'project'; fine-grained PAT: Organization permission 'Projects' and access to the org). Fix: set safe-outputs.update-project.github-token to a secret PAT that can access the target org project." - ) - : hasNotFound && - /projectV2\b/.test(error.message) && - core.error( - "GitHub returned NOT_FOUND for ProjectV2. This can mean either: (1) the project number is wrong for Projects v2, (2) the project is a classic Projects board (not Projects v2), or (3) the token does not have access to that org/user project." - ), - error.errors && - (core.error(`Errors array (${error.errors.length} error(s)):`), - error.errors.forEach((err, idx) => { - (core.error(` [${idx + 1}] ${err.message}`), - err.type && core.error(` Type: ${err.type}`), - err.path && core.error(` Path: ${JSON.stringify(err.path)}`), - err.locations && core.error(` Locations: ${JSON.stringify(err.locations)}`)); - })), - error.request && core.error(`Request: ${JSON.stringify(error.request, null, 2)}`), - error.data && core.error(`Response data: ${JSON.stringify(error.data, null, 2)}`)); - } - function parseProjectInput(projectUrl) { - if (!projectUrl || "string" != typeof projectUrl) throw new Error(`Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`); - const urlMatch = projectUrl.match(/github\.com\/(?:users|orgs)\/[^/]+\/projects\/(\d+)/); - if (!urlMatch) throw new Error(`Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`); - return urlMatch[1]; - } - function parseProjectUrl(projectUrl) { - if (!projectUrl || "string" != typeof projectUrl) throw new Error(`Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`); - const match = projectUrl.match(/github\.com\/(users|orgs)\/([^/]+)\/projects\/(\d+)/); - if (!match) throw new Error(`Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`); - return { scope: match[1], ownerLogin: match[2], projectNumber: match[3] }; - } - async function listAccessibleProjectsV2(projectInfo) { - const baseQuery = - "projectsV2(first: 100) {\n totalCount\n nodes {\n id\n number\n title\n closed\n url\n }\n edges {\n node {\n id\n number\n title\n closed\n url\n }\n }\n }"; - if ("orgs" === projectInfo.scope) { - const result = await github.graphql(`query($login: String!) {\n organization(login: $login) {\n ${baseQuery}\n }\n }`, { login: projectInfo.ownerLogin }), - conn = result && result.organization && result.organization.projectsV2, - rawNodes = conn && Array.isArray(conn.nodes) ? conn.nodes : [], - rawEdges = conn && Array.isArray(conn.edges) ? conn.edges : [], - nodeNodes = rawNodes.filter(Boolean), - edgeNodes = rawEdges.map(e => e && e.node).filter(Boolean), - unique = new Map(); - for (const n of [...nodeNodes, ...edgeNodes]) n && "string" == typeof n.id && unique.set(n.id, n); - return { - nodes: Array.from(unique.values()), - totalCount: conn && conn.totalCount, - diagnostics: { rawNodesCount: rawNodes.length, nullNodesCount: rawNodes.length - nodeNodes.length, rawEdgesCount: rawEdges.length, nullEdgeNodesCount: rawEdges.filter(e => !e || !e.node).length }, - }; - } - const result = await github.graphql(`query($login: String!) {\n user(login: $login) {\n ${baseQuery}\n }\n }`, { login: projectInfo.ownerLogin }), - conn = result && result.user && result.user.projectsV2, - rawNodes = conn && Array.isArray(conn.nodes) ? conn.nodes : [], - rawEdges = conn && Array.isArray(conn.edges) ? conn.edges : [], - nodeNodes = rawNodes.filter(Boolean), - edgeNodes = rawEdges.map(e => e && e.node).filter(Boolean), - unique = new Map(); - for (const n of [...nodeNodes, ...edgeNodes]) n && "string" == typeof n.id && unique.set(n.id, n); - return { - nodes: Array.from(unique.values()), - totalCount: conn && conn.totalCount, - diagnostics: { rawNodesCount: rawNodes.length, nullNodesCount: rawNodes.length - nodeNodes.length, rawEdgesCount: rawEdges.length, nullEdgeNodesCount: rawEdges.filter(e => !e || !e.node).length }, - }; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - function summarizeProjectsV2(projects, limit = 20) { - if (!Array.isArray(projects) || 0 === projects.length) return "(none)"; - const normalized = projects - .filter(p => p && "number" == typeof p.number && "string" == typeof p.title) - .slice(0, limit) - .map(p => `#${p.number} ${p.closed ? "(closed) " : ""}${p.title}`); - return normalized.length > 0 ? normalized.join("; ") : "(none)"; - } - function summarizeEmptyProjectsV2List(list) { - const total = "number" == typeof list.totalCount ? list.totalCount : void 0, - d = list && list.diagnostics, - diag = d ? ` nodes=${d.rawNodesCount} (null=${d.nullNodesCount}), edges=${d.rawEdgesCount} (nullNode=${d.nullEdgeNodesCount})` : ""; - return "number" == typeof total && total > 0 - ? `(none; totalCount=${total} but returned 0 readable project nodes${diag}. This often indicates the token can see the org/user but lacks Projects v2 access, or the org enforces SSO and the token is not authorized.)` - : `(none${diag})`; - } - async function resolveProjectV2(projectInfo, projectNumberInt) { + (async () => { try { - if ("orgs" === projectInfo.scope) { - const direct = await github.graphql( - "query($login: String!, $number: Int!) {\n organization(login: $login) {\n projectV2(number: $number) {\n id\n number\n title\n url\n }\n }\n }", - { login: projectInfo.ownerLogin, number: projectNumberInt } - ), - project = direct && direct.organization && direct.organization.projectV2; - if (project) return project; - } else { - const direct = await github.graphql( - "query($login: String!, $number: Int!) {\n user(login: $login) {\n projectV2(number: $number) {\n id\n number\n title\n url\n }\n }\n }", - { login: projectInfo.ownerLogin, number: projectNumberInt } - ), - project = direct && direct.user && direct.user.projectV2; - if (project) return project; - } - } catch (error) { - core.warning(`Direct projectV2(number) query failed; falling back to projectsV2 list search: ${error.message}`); - } - const list = await listAccessibleProjectsV2(projectInfo), - nodes = Array.isArray(list.nodes) ? list.nodes : [], - found = nodes.find(p => p && "number" == typeof p.number && p.number === projectNumberInt); - if (found) return found; - const summary = nodes.length > 0 ? summarizeProjectsV2(nodes) : summarizeEmptyProjectsV2List(list), - total = "number" == typeof list.totalCount ? ` (totalCount=${list.totalCount})` : "", - who = "orgs" === projectInfo.scope ? `org ${projectInfo.ownerLogin}` : `user ${projectInfo.ownerLogin}`; - throw new Error(`Project #${projectNumberInt} not found or not accessible for ${who}.${total} Accessible Projects v2: ${summary}`); - } - function generateCampaignId(projectUrl, projectNumber) { - const urlMatch = projectUrl.match(/github\.com\/(users|orgs)\/([^/]+)\/projects/); - return `${`${urlMatch ? urlMatch[2] : "project"}-project-${projectNumber}` - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .substring(0, 30)}-${Date.now().toString(36).substring(0, 8)}`; - } - async function updateProject(output) { - const { owner, repo } = context.repo, - projectInfo = parseProjectUrl(output.project), - projectNumberFromUrl = projectInfo.projectNumber, - campaignId = output.campaign_id || generateCampaignId(output.project, projectNumberFromUrl); - try { - let repoResult; - (core.info(`Looking up project #${projectNumberFromUrl} from URL: ${output.project}`), core.info("[1/5] Fetching repository information...")); - try { - repoResult = await github.graphql( - "query($owner: String!, $repo: String!) {\n repository(owner: $owner, name: $repo) {\n id\n owner {\n id\n __typename\n }\n }\n }", - { owner, repo } - ); - } catch (error) { - throw (logGraphQLError(error, "Fetching repository information"), error); - } - const repositoryId = repoResult.repository.id, - ownerType = repoResult.repository.owner.__typename; - core.info(`✓ Repository: ${owner}/${repo} (${ownerType})`); - try { - const viewerResult = await github.graphql("query {\n viewer {\n login\n }\n }"); - viewerResult && viewerResult.viewer && viewerResult.viewer.login && core.info(`✓ Authenticated as: ${viewerResult.viewer.login}`); - } catch (viewerError) { - core.warning(`Could not resolve token identity (viewer.login): ${viewerError.message}`); - } - let projectId; - core.info(`[2/5] Resolving project from URL (scope=${projectInfo.scope}, login=${projectInfo.ownerLogin}, number=${projectNumberFromUrl})...`); - let resolvedProjectNumber = projectNumberFromUrl; - try { - const projectNumberInt = parseInt(projectNumberFromUrl, 10); - if (!Number.isFinite(projectNumberInt)) throw new Error(`Invalid project number parsed from URL: ${projectNumberFromUrl}`); - const project = await resolveProjectV2(projectInfo, projectNumberInt); - ((projectId = project.id), (resolvedProjectNumber = String(project.number)), core.info(`✓ Resolved project #${resolvedProjectNumber} (${projectInfo.ownerLogin}) (ID: ${projectId})`)); - } catch (error) { - throw (logGraphQLError(error, "Resolving project from URL"), error); - } - core.info("[3/5] Linking project to repository..."); - try { - await github.graphql( - "mutation($projectId: ID!, $repositoryId: ID!) {\n linkProjectV2ToRepository(input: {\n projectId: $projectId,\n repositoryId: $repositoryId\n }) {\n repository {\n id\n }\n }\n }", - { projectId, repositoryId } - ); - } catch (linkError) { - (linkError.message && linkError.message.includes("already linked")) || (logGraphQLError(linkError, "Linking project to repository"), core.warning(`Could not link project: ${linkError.message}`)); - } - (core.info("✓ Project linked to repository"), core.info("[4/5] Processing content (issue/PR) if specified...")); - const hasContentNumber = void 0 !== output.content_number && null !== output.content_number, - hasIssue = void 0 !== output.issue && null !== output.issue, - hasPullRequest = void 0 !== output.pull_request && null !== output.pull_request, - values = []; - if ( - (hasContentNumber && values.push({ key: "content_number", value: output.content_number }), - hasIssue && values.push({ key: "issue", value: output.issue }), - hasPullRequest && values.push({ key: "pull_request", value: output.pull_request }), - values.length > 1) - ) { - const uniqueValues = [...new Set(values.map(v => String(v.value)))], - list = values.map(v => `${v.key}=${v.value}`).join(", "), - descriptor = uniqueValues.length > 1 ? "different values" : `same value "${uniqueValues[0]}"`; - core.warning(`Multiple content number fields (${descriptor}): ${list}. Using priority content_number > issue > pull_request.`); - } - (hasIssue && core.warning('Field "issue" deprecated; use "content_number" instead.'), hasPullRequest && core.warning('Field "pull_request" deprecated; use "content_number" instead.')); - let contentNumber = null; - if (hasContentNumber || hasIssue || hasPullRequest) { - const rawContentNumber = hasContentNumber ? output.content_number : hasIssue ? output.issue : output.pull_request, - sanitizedContentNumber = null == rawContentNumber ? "" : "number" == typeof rawContentNumber ? rawContentNumber.toString() : String(rawContentNumber).trim(); - if (sanitizedContentNumber) { - if (!/^\d+$/.test(sanitizedContentNumber)) throw new Error(`Invalid content number "${rawContentNumber}". Provide a positive integer.`); - contentNumber = Number.parseInt(sanitizedContentNumber, 10); - } else core.warning("Content number field provided but empty; skipping project item update."); - } - if (null !== contentNumber) { - const contentType = "pull_request" === output.content_type ? "PullRequest" : "issue" === output.content_type || output.issue ? "Issue" : "PullRequest", - contentQuery = - "Issue" === contentType - ? "query($owner: String!, $repo: String!, $number: Int!) {\n repository(owner: $owner, name: $repo) {\n issue(number: $number) {\n id\n }\n }\n }" - : "query($owner: String!, $repo: String!, $number: Int!) {\n repository(owner: $owner, name: $repo) {\n pullRequest(number: $number) {\n id\n }\n }\n }", - contentResult = await github.graphql(contentQuery, { owner, repo, number: contentNumber }), - contentId = "Issue" === contentType ? contentResult.repository.issue.id : contentResult.repository.pullRequest.id, - existingItem = await (async function (projectId, contentId) { - let hasNextPage = !0, - endCursor = null; - for (; hasNextPage; ) { - const result = await github.graphql( - "query($projectId: ID!, $after: String) {\n node(id: $projectId) {\n ... on ProjectV2 {\n items(first: 100, after: $after) {\n nodes {\n id\n content {\n ... on Issue {\n id\n }\n ... on PullRequest {\n id\n }\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n }\n }", - { projectId, after: endCursor } - ), - found = result.node.items.nodes.find(item => item.content && item.content.id === contentId); - if (found) return found; - ((hasNextPage = result.node.items.pageInfo.hasNextPage), (endCursor = result.node.items.pageInfo.endCursor)); - } - return null; - })(projectId, contentId); - let itemId; - if (existingItem) ((itemId = existingItem.id), core.info("✓ Item already on board")); - else { - itemId = ( - await github.graphql( - "mutation($projectId: ID!, $contentId: ID!) {\n addProjectV2ItemById(input: {\n projectId: $projectId,\n contentId: $contentId\n }) {\n item {\n id\n }\n }\n }", - { projectId, contentId } - ) - ).addProjectV2ItemById.item.id; - try { - await github.rest.issues.addLabels({ owner, repo, issue_number: contentNumber, labels: [`campaign:${campaignId}`] }); - } catch (labelError) { - core.warning(`Failed to add campaign label: ${labelError.message}`); - } - } - if (output.fields && Object.keys(output.fields).length > 0) { - const projectFields = ( - await github.graphql( - "query($projectId: ID!) {\n node(id: $projectId) {\n ... on ProjectV2 {\n fields(first: 20) {\n nodes {\n ... on ProjectV2Field {\n id\n name\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n options {\n id\n name\n color\n }\n }\n }\n }\n }\n }\n }", - { projectId } - ) - ).node.fields.nodes; - for (const [fieldName, fieldValue] of Object.entries(output.fields)) { - const normalizedFieldName = fieldName - .split(/[\s_-]+/) - .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(" "); - let valueToSet, - field = projectFields.find(f => f.name.toLowerCase() === normalizedFieldName.toLowerCase()); - if (!field) - if ("classification" === fieldName.toLowerCase() || ("string" == typeof fieldValue && fieldValue.includes("|"))) - try { - field = ( - await github.graphql( - "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType\n }) {\n projectV2Field {\n ... on ProjectV2Field {\n id\n name\n }\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n }\n }\n }", - { projectId, name: normalizedFieldName, dataType: "TEXT" } - ) - ).createProjectV2Field.projectV2Field; - } catch (createError) { - core.warning(`Failed to create field "${fieldName}": ${createError.message}`); - continue; - } - else - try { - field = ( - await github.graphql( - "mutation($projectId: ID!, $name: String!, $dataType: ProjectV2CustomFieldType!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {\n createProjectV2Field(input: {\n projectId: $projectId,\n name: $name,\n dataType: $dataType,\n singleSelectOptions: $options\n }) {\n projectV2Field {\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n ... on ProjectV2Field {\n id\n name\n }\n }\n }\n }", - { projectId, name: normalizedFieldName, dataType: "SINGLE_SELECT", options: [{ name: String(fieldValue), description: "", color: "GRAY" }] } - ) - ).createProjectV2Field.projectV2Field; - } catch (createError) { - core.warning(`Failed to create field "${fieldName}": ${createError.message}`); - continue; - } - if (field.options) { - let option = field.options.find(o => o.name === fieldValue); - if (!option) - try { - const allOptions = [...field.options.map(o => ({ name: o.name, description: "", color: o.color || "GRAY" })), { name: String(fieldValue), description: "", color: "GRAY" }], - updatedField = ( - await github.graphql( - "mutation($fieldId: ID!, $fieldName: String!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {\n updateProjectV2Field(input: {\n fieldId: $fieldId,\n name: $fieldName,\n singleSelectOptions: $options\n }) {\n projectV2Field {\n ... on ProjectV2SingleSelectField {\n id\n options {\n id\n name\n }\n }\n }\n }\n }", - { fieldId: field.id, fieldName: field.name, options: allOptions } - ) - ).updateProjectV2Field.projectV2Field; - ((option = updatedField.options.find(o => o.name === fieldValue)), (field = updatedField)); - } catch (createError) { - core.warning(`Failed to create option "${fieldValue}": ${createError.message}`); - continue; - } - if (!option) { - core.warning(`Could not get option ID for "${fieldValue}" in field "${fieldName}"`); - continue; - } - valueToSet = { singleSelectOptionId: option.id }; - } else valueToSet = { text: String(fieldValue) }; - await github.graphql( - "mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) {\n updateProjectV2ItemFieldValue(input: {\n projectId: $projectId,\n itemId: $itemId,\n fieldId: $fieldId,\n value: $value\n }) {\n projectV2Item {\n id\n }\n }\n }", - { projectId, itemId, fieldId: field.id, value: valueToSet } - ); - } - } - core.setOutput("item-id", itemId); - } + await main(); } catch (error) { - if (error.message && error.message.includes("does not have permission to create projects")) { - const usingCustomToken = !!process.env.GH_AW_PROJECT_GITHUB_TOKEN; - core.error( - `Failed to manage project: ${error.message}\n\nTroubleshooting:\n • Create the project manually at https://github.com/orgs/${owner}/projects/new.\n • Or supply a PAT (classic with project + repo scopes, or fine-grained with Projects: Read+Write) via GH_AW_PROJECT_GITHUB_TOKEN.\n • Or use a GitHub App with Projects: Read+Write permission.\n • Ensure the workflow grants projects: write.\n\n` + - (usingCustomToken ? "GH_AW_PROJECT_GITHUB_TOKEN is set but lacks access." : "Using default GITHUB_TOKEN - this cannot access Projects v2 API. You must configure GH_AW_PROJECT_GITHUB_TOKEN.") - ); - } else core.error(`Failed to manage project: ${error.message}`); - throw error; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - async function main() { - const result = loadAgentOutput(); - if (!result.success) return; - const updateProjectItems = result.items.filter(item => "update_project" === item.type); - if (0 !== updateProjectItems.length) - for (let i = 0; i < updateProjectItems.length; i++) { - const output = updateProjectItems[i]; - try { - await updateProject(output); - } catch (error) { - (core.error(`Failed to process item ${i + 1}`), logGraphQLError(error, `Processing update_project item ${i + 1}`)); - } - } - } - ("undefined" != typeof module && module.exports && (module.exports = { updateProject, parseProjectInput, generateCampaignId, main }), ("undefined" != typeof module && require.main !== module) || main()); + })(); diff --git a/.github/workflows/go-logger.lock.yml b/.github/workflows/go-logger.lock.yml index 0146fcb4c1..0cad95bfd2 100644 --- a/.github/workflows/go-logger.lock.yml +++ b/.github/workflows/go-logger.lock.yml @@ -6398,8 +6398,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6412,775 +6420,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Create Pull Request - id: create_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PR_TITLE_PREFIX: "[log] " GH_AW_PR_LABELS: "enhancement,automation" - GH_AW_PR_DRAFT: "false" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Go Logger Enhancement" - GH_AW_ENGINE_ID: "claude" - with: + GH_AW_PR_TITLE_PREFIX: "[log] " +GH_AW_PR_DRAFT=false with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { - core.setOutput("pull_request_number", ""); - core.setOutput("pull_request_url", ""); core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); - core.setOutput("branch_name", ""); - core.setOutput("fallback_used", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const workflowId = process.env.GH_AW_WORKFLOW_ID; - if (!workflowId) { - throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); - } - const baseBranch = process.env.GH_AW_BASE_BRANCH; - if (!baseBranch) { - throw new Error("GH_AW_BASE_BRANCH environment variable is required"); - } - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - let outputContent = ""; - if (agentOutputFile.trim() !== "") { - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - } - const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn"; - const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - let patchContent = ""; - let isEmpty = true; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } - if (patchContent.includes("Failed to generate patch")) { - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return; - } - throw new Error(message); - } - core.info("Patch size validation passed"); - } - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to push - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); return; } - core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - if (!title) { - title = "Agent Output"; - } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - const labelsEnv = process.env.GH_AW_PR_LABELS; - const labels = labelsEnv - ? labelsEnv - .split(",") - .map( label => label.trim()) - .filter( label => label) - : []; - const draftEnv = process.env.GH_AW_PR_DRAFT; - const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); - const randomHex = crypto.randomBytes(8).toString("hex"); - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); } else { - branchName = `${branchName}-${randomHex}`; - core.info(`Using branch name from JSONL with added salt: ${branchName}`); - } - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); - core.info(`Fetching latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${baseBranch}`); - core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`); - await exec.exec(`git checkout -b ${branchName}`); - core.info(`Created new branch from base: ${branchName}`); - if (!isEmpty) { - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - try { - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - try { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Changes pushed to branch"); - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - > [!NOTE] - > This was originally intended as a pull request, but the git push operation failed. - > - > **Workflow Run:** [View run details and download patch artifact](${runUrl}) - > - > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above. - To apply the patch locally: - \`\`\`sh - # Download the artifact from the workflow run ${runUrl} - # (Use GitHub MCP tools if gh CLI is not available) - gh run download ${runId} -n aw.patch - # Apply the patch - git am aw.patch - \`\`\` - ${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - core.setOutput("push_failed", "true"); - await core.summary - .addRaw( - ` - ## Push Failure Fallback - - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} - - **Fallback Issue:** [#${issue.number}](${issue.html_url}) - - **Patch Artifact:** Available in workflow run artifacts - - **Note:** Push failed, created issue as fallback - ` - ) - .write(); - return; - } catch (issueError) { - core.setFailed( - `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` - ); - return; } } - } else { - core.info("Skipping patch application (empty patch)"); - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - try { - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Empty branch pushed successfully"); - } catch (pushError) { - core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - return; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - } else { - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to apply - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } + await core.summary.addRaw(summaryContent).write(); } + core.info(`Completed processing ${result.items.length} safe output item(s)`); + } + (async () => { try { - const { data: pullRequest } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }); - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - labels: labels, - }); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } - core.setOutput("pull_request_number", pullRequest.number); - core.setOutput("pull_request_url", pullRequest.html_url); - core.setOutput("branch_name", branchName); - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - await core.summary - .addRaw( - ` - ## Pull Request - - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - - **Branch**: \`${branchName}\` - - **Base Branch**: \`${baseBranch}\` - ` - ) - .write(); - } catch (prError) { - core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); - core.info("Falling back to creating an issue instead"); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). - **Original error:** ${prError instanceof Error ? prError.message : String(prError)} - You can manually create a pull request from the branch if needed.${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - await core.summary - .addRaw( - ` - ## Fallback Issue Created - - **Issue**: [#${issue.number}](${issue.html_url}) - - **Branch**: [\`${branchName}\`](${branchUrl}) - - **Base Branch**: \`${baseBranch}\` - - **Note**: Pull request creation failed, created issue as fallback - ` - ) - .write(); - } catch (issueError) { - core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`); - return; - } + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/go-pattern-detector.lock.yml b/.github/workflows/go-pattern-detector.lock.yml index 0c11aa5748..c750ba3472 100644 --- a/.github/workflows/go-pattern-detector.lock.yml +++ b/.github/workflows/go-pattern-detector.lock.yml @@ -6262,9 +6262,16 @@ jobs: issues: write timeout-minutes: 15 outputs: - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6277,944 +6284,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_ISSUE_TITLE_PREFIX: "[ast-grep] " GH_AW_ISSUE_LABELS: "code-quality,ast-grep" - GH_AW_WORKFLOW_NAME: "Go Pattern Detector" - GH_AW_ENGINE_ID: "claude" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; + summaryContent += `**Repository:** ${item.repo}\n\n`; } if (item.body) { - content += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } - return content; - }, - }); + summaryContent += "---\n\n"; + } + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } - } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; - } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; } await core.summary.addRaw(summaryContent).write(); } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } (async () => { - await main(); + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } })(); diff --git a/.github/workflows/grumpy-reviewer.lock.yml b/.github/workflows/grumpy-reviewer.lock.yml index 13ae70638f..4a44e868f8 100644 --- a/.github/workflows/grumpy-reviewer.lock.yml +++ b/.github/workflows/grumpy-reviewer.lock.yml @@ -7668,8 +7668,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7682,1369 +7690,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Grumpy Code Reviewer 🔥" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 😤 *Reluctantly reviewed by [{workflow_name}]({run_url})*\",\"runStarted\":\"😤 *sigh* [{workflow_name}]({run_url}) is begrudgingly looking at this {event_type}... This better be worth my time.\",\"runSuccess\":\"😤 Fine. [{workflow_name}]({run_url}) finished the review. It wasn't completely terrible. I guess. 🙄\",\"runFailure\":\"😤 Great. [{workflow_name}]({run_url}) {status}. As if my day couldn't get any worse...\"}" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } - } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; - } - } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); - return; - } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; - } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } + summaryContent += "---\n\n"; } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; - } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); - } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); - } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); - } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; - } - (async () => { await main(); })(); - - name: Create PR Review Comment - id: create_pr_review_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request_review_comment')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PR_REVIEW_COMMENT_SIDE: "RIGHT" - GH_AW_WORKFLOW_NAME: "Grumpy Code Reviewer 🔥" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 😤 *Reluctantly reviewed by [{workflow_name}]({run_url})*\",\"runStarted\":\"😤 *sigh* [{workflow_name}]({run_url}) is begrudgingly looking at this {event_type}... This better be worth my time.\",\"runSuccess\":\"😤 Fine. [{workflow_name}]({run_url}) finished the review. It wasn't completely terrible. I guess. 🙄\",\"runFailure\":\"😤 Great. [{workflow_name}]({run_url}) {status}. As if my day couldn't get any worse...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const result = loadAgentOutput(); - if (!result.success) { + core.info("📝 Safe outputs preview written to step summary"); return; } - const reviewCommentItems = result.items.filter( item => item.type === "create_pull_request_review_comment"); - if (reviewCommentItems.length === 0) { - core.info("No create-pull-request-review-comment items found in agent output"); - return; - } - core.info(`Found ${reviewCommentItems.length} create-pull-request-review-comment item(s)`); - if (isStaged) { - await generateStagedPreview({ - title: "Create PR Review Comments", - description: "The following review comments would be created if staged mode was disabled:", - items: reviewCommentItems, - renderItem: (item, index) => { - let content = `#### Review Comment ${index + 1}\n`; - if (item.pull_request_number) { - const repoUrl = getRepositoryUrl(); - const pullUrl = `${repoUrl}/pull/${item.pull_request_number}`; - content += `**Target PR:** [#${item.pull_request_number}](${pullUrl})\n\n`; - } else { - content += `**Target:** Current PR\n\n`; - } - content += `**File:** ${item.path || "No path provided"}\n\n`; - content += `**Line:** ${item.line || "No line provided"}\n\n`; - if (item.start_line) { - content += `**Start Line:** ${item.start_line}\n\n`; - } - content += `**Side:** ${item.side || "RIGHT"}\n\n`; - content += `**Body:**\n${item.body || "No content provided"}\n\n`; - return content; - }, - }); - return; - } - const defaultSide = process.env.GH_AW_PR_REVIEW_COMMENT_SIDE || "RIGHT"; - core.info(`Default comment side configuration: ${defaultSide}`); - const commentTarget = process.env.GH_AW_PR_REVIEW_COMMENT_TARGET || "triggering"; - core.info(`PR review comment target configuration: ${commentTarget}`); - const isPRContext = - context.eventName === "pull_request" || - context.eventName === "pull_request_review" || - context.eventName === "pull_request_review_comment" || - (context.eventName === "issue_comment" && context.payload.issue && context.payload.issue.pull_request); - if (commentTarget === "triggering" && !isPRContext) { - core.info('Target is "triggering" but not running in pull request context, skipping review comment creation'); - return; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < reviewCommentItems.length; i++) { - const commentItem = reviewCommentItems[i]; - core.info( - `Processing create-pull-request-review-comment item ${i + 1}/${reviewCommentItems.length}: bodyLength=${commentItem.body ? commentItem.body.length : "undefined"}, path=${commentItem.path}, line=${commentItem.line}, startLine=${commentItem.start_line}` - ); - if (!commentItem.path) { - core.info('Missing required field "path" in review comment item'); - continue; - } - if (!commentItem.line || (typeof commentItem.line !== "number" && typeof commentItem.line !== "string")) { - core.info('Missing or invalid required field "line" in review comment item'); - continue; - } - if (!commentItem.body || typeof commentItem.body !== "string") { - core.info('Missing or invalid required field "body" in review comment item'); - continue; - } - let pullRequestNumber; - let pullRequest; - if (commentTarget === "*") { - if (commentItem.pull_request_number) { - pullRequestNumber = parseInt(commentItem.pull_request_number, 10); - if (isNaN(pullRequestNumber) || pullRequestNumber <= 0) { - core.info(`Invalid pull request number specified: ${commentItem.pull_request_number}`); - continue; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - } else { - core.info('Target is "*" but no pull_request_number specified in comment item'); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - pullRequestNumber = parseInt(commentTarget, 10); - if (isNaN(pullRequestNumber) || pullRequestNumber <= 0) { - core.info(`Invalid pull request number in target configuration: ${commentTarget}`); - continue; - } - } else { - if (context.payload.pull_request) { - pullRequestNumber = context.payload.pull_request.number; - pullRequest = context.payload.pull_request; - } else if (context.payload.issue && context.payload.issue.pull_request) { - pullRequestNumber = context.payload.issue.number; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; } } - if (!pullRequestNumber) { - core.info("Could not determine pull request number"); - continue; - } - if (!pullRequest || !pullRequest.head || !pullRequest.head.sha) { - try { - const { data: fullPR } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pullRequestNumber, - }); - pullRequest = fullPR; - core.info(`Fetched full pull request details for PR #${pullRequestNumber}`); - } catch (error) { - core.info(`Failed to fetch pull request details for PR #${pullRequestNumber}: ${error instanceof Error ? error.message : String(error)}`); - continue; - } - } - if (!pullRequest || !pullRequest.head || !pullRequest.head.sha) { - core.info(`Pull request head commit SHA not found for PR #${pullRequestNumber} - cannot create review comment`); - continue; - } - core.info(`Creating review comment on PR #${pullRequestNumber}`); - const line = parseInt(commentItem.line, 10); - if (isNaN(line) || line <= 0) { - core.info(`Invalid line number: ${commentItem.line}`); - continue; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } - let startLine = undefined; - if (commentItem.start_line) { - startLine = parseInt(commentItem.start_line, 10); - if (isNaN(startLine) || startLine <= 0 || startLine > line) { - core.info(`Invalid start_line number: ${commentItem.start_line} (must be <= line: ${line})`); - continue; + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } } - const side = commentItem.side || defaultSide; - if (side !== "LEFT" && side !== "RIGHT") { - core.info(`Invalid side value: ${side} (must be LEFT or RIGHT)`); - continue; - } - let body = commentItem.body.trim(); - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - body += generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - core.info(`Creating review comment on PR #${pullRequestNumber} at ${commentItem.path}:${line}${startLine ? ` (lines ${startLine}-${line})` : ""} [${side}]`); - core.info(`Comment content length: ${body.length}`); - try { - const requestParams = { - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pullRequestNumber, - body: body, - path: commentItem.path, - commit_id: pullRequest && pullRequest.head ? pullRequest.head.sha : "", - line: line, - side: side, - }; - if (startLine !== undefined) { - requestParams.start_line = startLine; - requestParams.start_side = side; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } - const { data: comment } = await github.rest.pulls.createReviewComment(requestParams); - core.info("Created review comment #" + comment.id + ": " + comment.html_url); - createdComments.push(comment); - if (i === reviewCommentItems.length - 1) { - core.setOutput("review_comment_id", comment.id); - core.setOutput("review_comment_url", comment.html_url); - } - } catch (error) { - core.error(`✗ Failed to create review comment: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } - } - if (createdComments.length > 0) { - let summaryContent = "\n\n## GitHub PR Review Comments\n"; - for (const comment of createdComments) { - summaryContent += `- Review Comment #${comment.id}: [View Comment](${comment.html_url})\n`; } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} review comment(s)`); - return createdComments; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); update_cache_memory: needs: diff --git a/.github/workflows/hourly-ci-cleaner.lock.yml b/.github/workflows/hourly-ci-cleaner.lock.yml index 19b49ca868..921a05d691 100644 --- a/.github/workflows/hourly-ci-cleaner.lock.yml +++ b/.github/workflows/hourly-ci-cleaner.lock.yml @@ -6874,8 +6874,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6888,772 +6896,176 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Create Pull Request - id: create_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_PR_TITLE_PREFIX: "[ca] " - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Hourly CI Cleaner" - GH_AW_TRACKER_ID: "hourly-ci-cleaner" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { - core.setOutput("pull_request_number", ""); - core.setOutput("pull_request_url", ""); core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); - core.setOutput("branch_name", ""); - core.setOutput("fallback_used", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const workflowId = process.env.GH_AW_WORKFLOW_ID; - if (!workflowId) { - throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); - } - const baseBranch = process.env.GH_AW_BASE_BRANCH; - if (!baseBranch) { - throw new Error("GH_AW_BASE_BRANCH environment variable is required"); - } - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - let outputContent = ""; - if (agentOutputFile.trim() !== "") { - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - } - const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn"; - const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - let patchContent = ""; - let isEmpty = true; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } - if (patchContent.includes("Failed to generate patch")) { - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return; - } - throw new Error(message); - } - core.info("Patch size validation passed"); - } - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to push - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); return; } - core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - if (!title) { - title = "Agent Output"; - } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - const labelsEnv = process.env.GH_AW_PR_LABELS; - const labels = labelsEnv - ? labelsEnv - .split(",") - .map( label => label.trim()) - .filter( label => label) - : []; - const draftEnv = process.env.GH_AW_PR_DRAFT; - const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); - const randomHex = crypto.randomBytes(8).toString("hex"); - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); } else { - branchName = `${branchName}-${randomHex}`; - core.info(`Using branch name from JSONL with added salt: ${branchName}`); - } - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); - core.info(`Fetching latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${baseBranch}`); - core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`); - await exec.exec(`git checkout -b ${branchName}`); - core.info(`Created new branch from base: ${branchName}`); - if (!isEmpty) { - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - try { - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - try { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Changes pushed to branch"); - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - > [!NOTE] - > This was originally intended as a pull request, but the git push operation failed. - > - > **Workflow Run:** [View run details and download patch artifact](${runUrl}) - > - > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above. - To apply the patch locally: - \`\`\`sh - # Download the artifact from the workflow run ${runUrl} - # (Use GitHub MCP tools if gh CLI is not available) - gh run download ${runId} -n aw.patch - # Apply the patch - git am aw.patch - \`\`\` - ${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - core.setOutput("push_failed", "true"); - await core.summary - .addRaw( - ` - ## Push Failure Fallback - - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} - - **Fallback Issue:** [#${issue.number}](${issue.html_url}) - - **Patch Artifact:** Available in workflow run artifacts - - **Note:** Push failed, created issue as fallback - ` - ) - .write(); - return; - } catch (issueError) { - core.setFailed( - `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` - ); - return; } } - } else { - core.info("Skipping patch application (empty patch)"); - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - try { - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Empty branch pushed successfully"); - } catch (pushError) { - core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - return; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - } else { - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to apply - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } + await core.summary.addRaw(summaryContent).write(); } + core.info(`Completed processing ${result.items.length} safe output item(s)`); + } + (async () => { try { - const { data: pullRequest } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }); - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - labels: labels, - }); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } - core.setOutput("pull_request_number", pullRequest.number); - core.setOutput("pull_request_url", pullRequest.html_url); - core.setOutput("branch_name", branchName); - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - await core.summary - .addRaw( - ` - ## Pull Request - - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - - **Branch**: \`${branchName}\` - - **Base Branch**: \`${baseBranch}\` - ` - ) - .write(); - } catch (prError) { - core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); - core.info("Falling back to creating an issue instead"); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). - **Original error:** ${prError instanceof Error ? prError.message : String(prError)} - You can manually create a pull request from the branch if needed.${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - await core.summary - .addRaw( - ` - ## Fallback Issue Created - - **Issue**: [#${issue.number}](${issue.html_url}) - - **Branch**: [\`${branchName}\`](${branchUrl}) - - **Base Branch**: \`${baseBranch}\` - - **Note**: Pull request creation failed, created issue as fallback - ` - ) - .write(); - } catch (issueError) { - core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`); - return; - } + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); diff --git a/.github/workflows/human-ai-collaboration.lock.yml b/.github/workflows/human-ai-collaboration.lock.yml index 11d3201695..09e43f2010 100644 --- a/.github/workflows/human-ai-collaboration.lock.yml +++ b/.github/workflows/human-ai-collaboration.lock.yml @@ -7198,9 +7198,16 @@ jobs: issues: write timeout-minutes: 15 outputs: - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7213,942 +7220,175 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Human-AI Collaboration Campaign" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; + summaryContent += `**Repository:** ${item.repo}\n\n`; } if (item.body) { - content += `**Body:**\n${item.body}\n\n`; + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; - } - return content; - }, - }); + summaryContent += "---\n\n"; + } + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } - } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } - } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } await core.summary.addRaw(summaryContent).write(); } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } (async () => { - await main(); + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } })(); diff --git a/.github/workflows/incident-response.lock.yml b/.github/workflows/incident-response.lock.yml index 547fe32413..f9597cc7dd 100644 --- a/.github/workflows/incident-response.lock.yml +++ b/.github/workflows/incident-response.lock.yml @@ -7359,14 +7359,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} - add_labels_labels_added: ${{ steps.add_labels.outputs.labels_added }} - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} - create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7379,3034 +7381,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash - run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/safe_output_helpers.cjs << 'EOF_80a143d8' - // @ts-check - /// - - /** - * Shared helper functions for safe-output scripts - * Provides common validation and target resolution logic - */ - - /** - * Parse a comma-separated list of allowed items from environment variable - * @param {string|undefined} envValue - Environment variable value - * @returns {string[]|undefined} Array of allowed items, or undefined if no restrictions - */ - function parseAllowedItems(envValue) { - const trimmed = envValue?.trim(); - if (!trimmed) { - return undefined; - } - return trimmed - .split(",") - .map(item => item.trim()) - .filter(item => item); - } - - /** - * Parse and validate max count from environment variable - * @param {string|undefined} envValue - Environment variable value - * @param {number} defaultValue - Default value if not specified - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function parseMaxCount(envValue, defaultValue = 3) { - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - /** - * Resolve the target number (issue/PR) based on configuration and context - * @param {Object} params - Resolution parameters - * @param {string} params.targetConfig - Target configuration ("triggering", "*", or explicit number) - * @param {any} params.item - Safe output item with optional item_number or pull_request_number - * @param {any} params.context - GitHub Actions context - * @param {string} params.itemType - Type of item being processed (for error messages) - * @param {boolean} params.supportsPR - Whether this safe output supports PR context - * @returns {{success: true, number: number, contextType: string} | {success: false, error: string, shouldFail: boolean}} Resolution result - */ - function resolveTarget(params) { - const { targetConfig, item, context, itemType, supportsPR = false } = params; - - // Check context type - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - - // Default target is "triggering" - const target = targetConfig || "triggering"; - - // Validate context for triggering mode - if (target === "triggering") { - if (supportsPR) { - if (!isIssueContext && !isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in issue or pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } else { - if (!isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } - } - - // Resolve target number - let itemNumber; - let contextType; - - if (target === "*") { - // Use item_number, issue_number, or pull_request_number from item - const numberField = supportsPR ? item.item_number || item.issue_number || item.pull_request_number : item.pull_request_number; - - if (numberField) { - itemNumber = typeof numberField === "number" ? numberField : parseInt(String(numberField), 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "item_number/issue_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, - shouldFail: true, - }; - } - contextType = supportsPR && (item.item_number || item.issue_number) ? "issue" : "pull request"; - } else { - return { - success: false, - error: `Target is "*" but no ${supportsPR ? "item_number/issue_number" : "pull_request_number"} specified in ${itemType} item`, - shouldFail: true, - }; - } - } else if (target !== "triggering") { - // Explicit number - itemNumber = parseInt(target, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "issue" : "pull request"} number in target configuration: ${target}`, - shouldFail: true, - }; - } - contextType = supportsPR ? "issue" : "pull request"; - } else { - // Use triggering context - if (isIssueContext) { - if (context.payload.issue) { - itemNumber = context.payload.issue.number; - contextType = "issue"; - } else { - return { - success: false, - error: "Issue context detected but no issue found in payload", - shouldFail: true, - }; - } - } else if (isPRContext) { - if (context.payload.pull_request) { - itemNumber = context.payload.pull_request.number; - contextType = "pull request"; - } else { - return { - success: false, - error: "Pull request context detected but no pull request found in payload", - shouldFail: true, - }; - } - } - } - - if (!itemNumber) { - return { - success: false, - error: `Could not determine ${supportsPR ? "issue or pull request" : "pull request"} number`, - shouldFail: true, - }; - } - - return { - success: true, - number: itemNumber, - contextType: contextType || (supportsPR ? "issue" : "pull request"), - }; - } - - module.exports = { - parseAllowedItems, - parseMaxCount, - resolveTarget, - }; - - EOF_80a143d8 - cat > /tmp/gh-aw/scripts/safe_output_processor.cjs << 'EOF_8f3864e2' - // @ts-check - /// - - /** - * Shared processor for safe-output scripts - * Provides common pipeline: load agent output, handle staged mode, parse config, resolve target - */ - - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { parseAllowedItems, resolveTarget } = require('/tmp/gh-aw/scripts/safe_output_helpers.cjs'); - const { getSafeOutputConfig, validateMaxCount } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs'); - - /** - * @typedef {Object} ProcessorConfig - * @property {string} itemType - The type field value to match in agent output (e.g., "add_labels") - * @property {string} configKey - The key to use when reading from config.json (e.g., "add_labels") - * @property {string} displayName - Human-readable name for logging (e.g., "Add Labels") - * @property {string} itemTypeName - Name used in error messages (e.g., "label addition") - * @property {boolean} [supportsPR] - When true, allows both issue AND PR contexts; when false, only PR context (default: false) - * @property {boolean} [supportsIssue] - When true, passes supportsPR=true to resolveTarget to enable both contexts (default: false) - * @property {boolean} [findMultiple] - Whether to find multiple items instead of just one (default: false) - * @property {Object} envVars - Environment variable names - * @property {string} [envVars.allowed] - Env var for allowed items list - * @property {string} [envVars.maxCount] - Env var for max count - * @property {string} [envVars.target] - Env var for target configuration - */ - - /** - * @typedef {Object} ProcessorResult - * @property {boolean} success - Whether processing should continue - * @property {any} [item] - The found item (when findMultiple is false) - * @property {any[]} [items] - The found items (when findMultiple is true) - * @property {Object} [config] - Parsed configuration - * @property {string[]|undefined} [config.allowed] - Allowed items list - * @property {number} [config.maxCount] - Maximum count - * @property {string} [config.target] - Target configuration - * @property {Object} [targetResult] - Result from resolveTarget (when findMultiple is false) - * @property {number} [targetResult.number] - Target issue/PR number - * @property {string} [targetResult.contextType] - Type of context (issue or pull request) - * @property {string} [reason] - Reason why processing should not continue - */ - - /** - * Process the initial steps common to safe-output scripts: - * 1. Load agent output - * 2. Find matching item(s) - * 3. Handle staged mode - * 4. Parse configuration - * 5. Resolve target (for single-item processors) - * - * @param {ProcessorConfig} config - Processor configuration - * @param {Object} stagedPreviewOptions - Options for staged preview - * @param {string} stagedPreviewOptions.title - Title for staged preview - * @param {string} stagedPreviewOptions.description - Description for staged preview - * @param {(item: any, index: number) => string} stagedPreviewOptions.renderItem - Function to render item in preview - * @returns {Promise} Processing result - */ - async function processSafeOutput(config, stagedPreviewOptions) { - const { itemType, configKey, displayName, itemTypeName, supportsPR = false, supportsIssue = false, findMultiple = false, envVars } = config; - - // Step 1: Load agent output - const result = loadAgentOutput(); - if (!result.success) { - return { success: false, reason: "Agent output not available" }; - } - - // Step 2: Find matching item(s) - let items; - if (findMultiple) { - items = result.items.filter(item => item.type === itemType); - if (items.length === 0) { - core.info(`No ${itemType} items found in agent output`); - return { success: false, reason: `No ${itemType} items found` }; - } - core.info(`Found ${items.length} ${itemType} item(s)`); - } else { - const item = result.items.find(item => item.type === itemType); - if (!item) { - core.warning(`No ${itemType.replace(/_/g, "-")} item found in agent output`); - return { success: false, reason: `No ${itemType} item found` }; - } - items = [item]; - // Log item details based on common fields - const itemDetails = getItemDetails(item); - if (itemDetails) { - core.info(`Found ${itemType.replace(/_/g, "-")} item with ${itemDetails}`); - } - } - - // Step 3: Handle staged mode - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - await generateStagedPreview({ - title: stagedPreviewOptions.title, - description: stagedPreviewOptions.description, - items: items, - renderItem: stagedPreviewOptions.renderItem, - }); - return { success: false, reason: "Staged mode - preview generated" }; - } - - // Step 4: Parse configuration - const safeOutputConfig = getSafeOutputConfig(configKey); - - // Parse allowed items (from env or config) - const allowedEnvValue = envVars.allowed ? process.env[envVars.allowed] : undefined; - const allowed = parseAllowedItems(allowedEnvValue) || safeOutputConfig.allowed; - if (allowed) { - core.info(`Allowed ${itemTypeName}s: ${JSON.stringify(allowed)}`); - } else { - core.info(`No ${itemTypeName} restrictions - any ${itemTypeName}s are allowed`); - } - - // Parse max count (env takes priority, then config) - const maxCountEnvValue = envVars.maxCount ? process.env[envVars.maxCount] : undefined; - const maxCountResult = validateMaxCount(maxCountEnvValue, safeOutputConfig.max); - if (!maxCountResult.valid) { - core.setFailed(maxCountResult.error); - return { success: false, reason: "Invalid max count configuration" }; - } - const maxCount = maxCountResult.value; - core.info(`Max count: ${maxCount}`); - - // Get target configuration - const target = envVars.target ? process.env[envVars.target] || "triggering" : "triggering"; - core.info(`${displayName} target configuration: ${target}`); - - // For multiple items, return early without target resolution - if (findMultiple) { - return { - success: true, - items: items, - config: { - allowed, - maxCount, - target, - }, - }; - } - - // Step 5: Resolve target (for single-item processors) - const item = items[0]; - const targetResult = resolveTarget({ - targetConfig: target, - item: item, - context, - itemType: itemTypeName, - // supportsPR in resolveTarget: true=both issue and PR contexts, false=PR-only - // If supportsIssue is true, we pass supportsPR=true to enable both contexts - supportsPR: supportsPR || supportsIssue, - }); - - if (!targetResult.success) { - if (targetResult.shouldFail) { - core.setFailed(targetResult.error); - } else { - core.info(targetResult.error); - } - return { success: false, reason: targetResult.error }; - } - - return { - success: true, - item: item, - config: { - allowed, - maxCount, - target, - }, - targetResult: { - number: targetResult.number, - contextType: targetResult.contextType, - }, - }; - } - - /** - * Get a description of item details for logging - * @param {any} item - The safe output item - * @returns {string|null} Description string or null - */ - function getItemDetails(item) { - if (item.labels && Array.isArray(item.labels)) { - return `${item.labels.length} labels`; - } - if (item.reviewers && Array.isArray(item.reviewers)) { - return `${item.reviewers.length} reviewers`; - } - return null; - } - - /** - * Sanitize and deduplicate an array of string items - * @param {any[]} items - Raw items array - * @returns {string[]} Sanitized and deduplicated array - */ - function sanitizeItems(items) { - return items - .filter(item => item != null && item !== false && item !== 0) - .map(item => String(item).trim()) - .filter(item => item) - .filter((item, index, arr) => arr.indexOf(item) === index); - } - - /** - * Filter items by allowed list - * @param {string[]} items - Items to filter - * @param {string[]|undefined} allowed - Allowed items list (undefined means all allowed) - * @returns {string[]} Filtered items - */ - function filterByAllowed(items, allowed) { - if (!allowed || allowed.length === 0) { - return items; - } - return items.filter(item => allowed.includes(item)); - } - - /** - * Limit items to max count - * @param {string[]} items - Items to limit - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Limited items - */ - function limitToMaxCount(items, maxCount) { - if (items.length > maxCount) { - core.info(`Too many items (${items.length}), limiting to ${maxCount}`); - return items.slice(0, maxCount); - } - return items; - } - - /** - * Process items through the standard pipeline: filter by allowed, sanitize, dedupe, limit - * @param {any[]} rawItems - Raw items array from agent output - * @param {string[]|undefined} allowed - Allowed items list - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Processed items - */ - function processItems(rawItems, allowed, maxCount) { - // Filter by allowed list first - const filtered = filterByAllowed(rawItems, allowed); - - // Sanitize and deduplicate - const sanitized = sanitizeItems(filtered); - - // Limit to max count - return limitToMaxCount(sanitized, maxCount); - } - - module.exports = { - processSafeOutput, - sanitizeItems, - filterByAllowed, - limitToMaxCount, - processItems, - }; - - EOF_8f3864e2 - cat > /tmp/gh-aw/scripts/safe_output_validator.cjs << 'EOF_437e6b4f' - // @ts-check - /// - - const fs = require("fs"); - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - - /** - * Load and parse the safe outputs configuration from config.json - * @returns {object} The parsed configuration object - */ - function loadSafeOutputsConfig() { - const configPath = "/tmp/gh-aw/safeoutputs/config.json"; - try { - if (!fs.existsSync(configPath)) { - core.warning(`Config file not found at ${configPath}, using defaults`); - return {}; - } - const configContent = fs.readFileSync(configPath, "utf8"); - return JSON.parse(configContent); - } catch (error) { - core.warning(`Failed to load config: ${error instanceof Error ? error.message : String(error)}`); - return {}; - } - } - - /** - * Get configuration for a specific safe output type - * @param {string} outputType - The type of safe output (e.g., "add_labels", "update_issue") - * @returns {{max?: number, target?: string, allowed?: string[]}} The configuration for this output type - */ - function getSafeOutputConfig(outputType) { - const config = loadSafeOutputsConfig(); - return config[outputType] || {}; - } - - /** - * Validate and sanitize a title string - * @param {any} title - The title to validate - * @param {string} fieldName - The name of the field for error messages (default: "title") - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateTitle(title, fieldName = "title") { - if (title === undefined || title === null) { - return { valid: false, error: `${fieldName} is required` }; - } - - if (typeof title !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - const trimmed = title.trim(); - if (trimmed.length === 0) { - return { valid: false, error: `${fieldName} cannot be empty` }; - } - - return { valid: true, value: trimmed }; - } - - /** - * Validate and sanitize a body/content string - * @param {any} body - The body to validate - * @param {string} fieldName - The name of the field for error messages (default: "body") - * @param {boolean} required - Whether the body is required (default: false) - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateBody(body, fieldName = "body", required = false) { - if (body === undefined || body === null) { - if (required) { - return { valid: false, error: `${fieldName} is required` }; - } - return { valid: true, value: "" }; - } - - if (typeof body !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - return { valid: true, value: body }; - } - - /** - * Validate and sanitize an array of labels - * @param {any} labels - The labels to validate - * @param {string[]|undefined} allowedLabels - Optional list of allowed labels - * @param {number} maxCount - Maximum number of labels allowed - * @returns {{valid: boolean, value?: string[], error?: string}} Validation result - */ - function validateLabels(labels, allowedLabels = undefined, maxCount = 3) { - if (!labels || !Array.isArray(labels)) { - return { valid: false, error: "labels must be an array" }; - } - - // Check for removal attempts (labels starting with '-') - for (const label of labels) { - if (label && typeof label === "string" && label.startsWith("-")) { - return { valid: false, error: `Label removal is not permitted. Found line starting with '-': ${label}` }; - } - } - - // Filter labels based on allowed list if provided - let validLabels = labels; - if (allowedLabels && allowedLabels.length > 0) { - validLabels = labels.filter(label => allowedLabels.includes(label)); - } - - // Sanitize and deduplicate labels - const uniqueLabels = validLabels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - - // Apply max count limit - if (uniqueLabels.length > maxCount) { - core.info(`Too many labels (${uniqueLabels.length}), limiting to ${maxCount}`); - return { valid: true, value: uniqueLabels.slice(0, maxCount) }; - } - - if (uniqueLabels.length === 0) { - return { valid: false, error: "No valid labels found after sanitization" }; - } - - return { valid: true, value: uniqueLabels }; - } - - /** - * Validate max count from environment variable with config fallback - * @param {string|undefined} envValue - Environment variable value - * @param {number|undefined} configDefault - Default from config.json - * @param {number} [fallbackDefault] - Fallback default for testing (optional, defaults to 1) - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function validateMaxCount(envValue, configDefault, fallbackDefault = 1) { - // Priority: env var > config.json > fallback default - // In production, config.json should always have the default - // Fallback is provided for backward compatibility and testing - const defaultValue = configDefault !== undefined ? configDefault : fallbackDefault; - - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - module.exports = { - loadSafeOutputsConfig, - getSafeOutputConfig, - validateTitle, - validateBody, - validateLabels, - validateMaxCount, - }; - - EOF_437e6b4f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_ISSUE_LABELS: "campaign-tracker,incident" - GH_AW_WORKFLOW_NAME: "Campaign - Incident Response" - GH_AW_ENGINE_ID: "copilot" + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function main() { - core.setOutput("issue_number", ""); - core.setOutput("issue_url", ""); - core.setOutput("temporary_id_map", "{}"); - core.setOutput("issues_to_assign_copilot", ""); - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; - } - if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - content += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; - } - return content; - }, - }); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; - } - } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } - } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } - } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; - } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; - } - await core.summary.addRaw(summaryContent).write(); - } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); - } - (async () => { - await main(); - })(); - - name: Create Pull Request - id: create_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_ISSUE_LABELS: "campaign-tracker,incident" GH_AW_PR_LABELS: "campaign-fix,incident" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Campaign - Incident Response" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { - core.setOutput("pull_request_number", ""); - core.setOutput("pull_request_url", ""); core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); - core.setOutput("branch_name", ""); - core.setOutput("fallback_used", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const workflowId = process.env.GH_AW_WORKFLOW_ID; - if (!workflowId) { - throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); - } - const baseBranch = process.env.GH_AW_BASE_BRANCH; - if (!baseBranch) { - throw new Error("GH_AW_BASE_BRANCH environment variable is required"); - } - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - let outputContent = ""; - if (agentOutputFile.trim() !== "") { - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - } - const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn"; - const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - let patchContent = ""; - let isEmpty = true; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } - if (patchContent.includes("Failed to generate patch")) { - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return; - } - throw new Error(message); - } - core.info("Patch size validation passed"); - } - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to push - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); return; } - core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; - } - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); - return; - } - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - if (!title) { - title = "Agent Output"; - } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - const labelsEnv = process.env.GH_AW_PR_LABELS; - const labels = labelsEnv - ? labelsEnv - .split(",") - .map( label => label.trim()) - .filter( label => label) - : []; - const draftEnv = process.env.GH_AW_PR_DRAFT; - const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); - const randomHex = crypto.randomBytes(8).toString("hex"); - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; - } else { - branchName = `${branchName}-${randomHex}`; - core.info(`Using branch name from JSONL with added salt: ${branchName}`); - } - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); - core.info(`Fetching latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${baseBranch}`); - core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`); - await exec.exec(`git checkout -b ${branchName}`); - core.info(`Created new branch from base: ${branchName}`); - if (!isEmpty) { - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - try { - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - try { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Changes pushed to branch"); - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - > [!NOTE] - > This was originally intended as a pull request, but the git push operation failed. - > - > **Workflow Run:** [View run details and download patch artifact](${runUrl}) - > - > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above. - To apply the patch locally: - \`\`\`sh - # Download the artifact from the workflow run ${runUrl} - # (Use GitHub MCP tools if gh CLI is not available) - gh run download ${runId} -n aw.patch - # Apply the patch - git am aw.patch - \`\`\` - ${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - core.setOutput("push_failed", "true"); - await core.summary - .addRaw( - ` - ## Push Failure Fallback - - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} - - **Fallback Issue:** [#${issue.number}](${issue.html_url}) - - **Patch Artifact:** Available in workflow run artifacts - - **Note:** Push failed, created issue as fallback - ` - ) - .write(); - return; - } catch (issueError) { - core.setFailed( - `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` - ); - return; - } - } - } else { - core.info("Skipping patch application (empty patch)"); - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - try { - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; } - await exec.exec(`git push origin ${branchName}`); - core.info("Empty branch pushed successfully"); - } catch (pushError) { - core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - return; - } - } else { - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to apply - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - try { - const { data: pullRequest } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }); - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - labels: labels, - }); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } - core.setOutput("pull_request_number", pullRequest.number); - core.setOutput("pull_request_url", pullRequest.html_url); - core.setOutput("branch_name", branchName); - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - await core.summary - .addRaw( - ` - ## Pull Request - - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - - **Branch**: \`${branchName}\` - - **Base Branch**: \`${baseBranch}\` - ` - ) - .write(); - } catch (prError) { - core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); - core.info("Falling back to creating an issue instead"); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). - **Original error:** ${prError instanceof Error ? prError.message : String(prError)} - You can manually create a pull request from the branch if needed.${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - await core.summary - .addRaw( - ` - ## Fallback Issue Created - - **Issue**: [#${issue.number}](${issue.html_url}) - - **Branch**: [\`${branchName}\`](${branchUrl}) - - **Base Branch**: \`${baseBranch}\` - - **Note**: Pull request creation failed, created issue as fallback - ` - ) - .write(); - } catch (issueError) { - core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`); - return; - } - } - } - (async () => { await main(); })(); - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_CREATED_ISSUE_URL: ${{ steps.create_issue.outputs.issue_url }} - GH_AW_CREATED_ISSUE_NUMBER: ${{ steps.create_issue.outputs.issue_number }} - GH_AW_TEMPORARY_ID_MAP: ${{ steps.create_issue.outputs.temporary_id_map }} - GH_AW_CREATED_PULL_REQUEST_URL: ${{ steps.create_pull_request.outputs.pull_request_url }} - GH_AW_CREATED_PULL_REQUEST_NUMBER: ${{ steps.create_pull_request.outputs.pull_request_number }} - GH_AW_WORKFLOW_NAME: "Campaign - Incident Response" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } + summaryContent += "---\n\n"; } } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } - } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } - const result = loadAgentOutput(); - if (!result.success) { + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } } - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; - } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); - return; } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; - } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; - } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); - } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); - } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); - - name: Add Labels - id: add_labels - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_labels')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Campaign - Incident Response" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { processSafeOutput } = require('/tmp/gh-aw/scripts/safe_output_processor.cjs'); - const { validateLabels } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs'); - async function main() { - const result = await processSafeOutput( - { - itemType: "add_labels", - configKey: "add_labels", - displayName: "Labels", - itemTypeName: "label addition", - supportsPR: true, - supportsIssue: true, - envVars: { - allowed: "GH_AW_LABELS_ALLOWED", - maxCount: "GH_AW_LABELS_MAX_COUNT", - target: "GH_AW_LABELS_TARGET", - }, - }, - { - title: "Add Labels", - description: "The following labels would be added if staged mode was disabled:", - renderItem: item => { - let content = ""; - if (item.item_number) { - content += `**Target Issue:** #${item.item_number}\n\n`; - } else { - content += `**Target:** Current issue/PR\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels to add:** ${item.labels.join(", ")}\n\n`; - } - return content; - }, - } - ); - if (!result.success) { - return; - } - const { item: labelsItem, config, targetResult } = result; - if (!config || !targetResult || targetResult.number === undefined) { - core.setFailed("Internal error: config, targetResult, or targetResult.number is undefined"); - return; - } - const { allowed: allowedLabels, maxCount } = config; - const itemNumber = targetResult.number; - const { contextType } = targetResult; - const requestedLabels = labelsItem.labels || []; - core.info(`Requested labels: ${JSON.stringify(requestedLabels)}`); - const labelsResult = validateLabels(requestedLabels, allowedLabels, maxCount); - if (!labelsResult.valid) { - if (labelsResult.error && labelsResult.error.includes("No valid labels")) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` - ## Label Addition - No labels were added (no valid labels found in agent output). - ` - ) - .write(); - return; - } - core.setFailed(labelsResult.error || "Invalid labels"); - return; - } - const uniqueLabels = labelsResult.value || []; - if (uniqueLabels.length === 0) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` - ## Label Addition - No labels were added (no valid labels found in agent output). - ` - ) - .write(); - return; - } - core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${itemNumber}: ${JSON.stringify(uniqueLabels)}`); + (async () => { try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - labels: uniqueLabels, - }); - core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${itemNumber}`); - core.setOutput("labels_added", uniqueLabels.join("\n")); - const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); - await core.summary - .addRaw( - ` - ## Label Addition - Successfully added ${uniqueLabels.length} label(s) to ${contextType} #${itemNumber}: - ${labelsListMarkdown} - ` - ) - .write(); + await main(); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to add labels: ${errorMessage}`); - core.setFailed(`Failed to add labels: ${errorMessage}`); + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); diff --git a/.github/workflows/instructions-janitor.lock.yml b/.github/workflows/instructions-janitor.lock.yml index 5939577569..aae0a11d31 100644 --- a/.github/workflows/instructions-janitor.lock.yml +++ b/.github/workflows/instructions-janitor.lock.yml @@ -6278,8 +6278,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6292,775 +6300,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Create Pull Request - id: create_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PR_TITLE_PREFIX: "[instructions] " GH_AW_PR_LABELS: "documentation,automation,instructions" - GH_AW_PR_DRAFT: "false" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Instructions Janitor" - GH_AW_ENGINE_ID: "claude" - with: + GH_AW_PR_TITLE_PREFIX: "[instructions] " +GH_AW_PR_DRAFT=false with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { - core.setOutput("pull_request_number", ""); - core.setOutput("pull_request_url", ""); core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); - core.setOutput("branch_name", ""); - core.setOutput("fallback_used", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const workflowId = process.env.GH_AW_WORKFLOW_ID; - if (!workflowId) { - throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); - } - const baseBranch = process.env.GH_AW_BASE_BRANCH; - if (!baseBranch) { - throw new Error("GH_AW_BASE_BRANCH environment variable is required"); - } - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - let outputContent = ""; - if (agentOutputFile.trim() !== "") { - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - } - const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn"; - const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - let patchContent = ""; - let isEmpty = true; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } - if (patchContent.includes("Failed to generate patch")) { - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return; - } - throw new Error(message); - } - core.info("Patch size validation passed"); - } - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to push - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); return; } - core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - if (!title) { - title = "Agent Output"; - } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - const labelsEnv = process.env.GH_AW_PR_LABELS; - const labels = labelsEnv - ? labelsEnv - .split(",") - .map( label => label.trim()) - .filter( label => label) - : []; - const draftEnv = process.env.GH_AW_PR_DRAFT; - const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); - const randomHex = crypto.randomBytes(8).toString("hex"); - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); } else { - branchName = `${branchName}-${randomHex}`; - core.info(`Using branch name from JSONL with added salt: ${branchName}`); - } - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); - core.info(`Fetching latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${baseBranch}`); - core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`); - await exec.exec(`git checkout -b ${branchName}`); - core.info(`Created new branch from base: ${branchName}`); - if (!isEmpty) { - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - try { - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - try { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Changes pushed to branch"); - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - > [!NOTE] - > This was originally intended as a pull request, but the git push operation failed. - > - > **Workflow Run:** [View run details and download patch artifact](${runUrl}) - > - > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above. - To apply the patch locally: - \`\`\`sh - # Download the artifact from the workflow run ${runUrl} - # (Use GitHub MCP tools if gh CLI is not available) - gh run download ${runId} -n aw.patch - # Apply the patch - git am aw.patch - \`\`\` - ${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - core.setOutput("push_failed", "true"); - await core.summary - .addRaw( - ` - ## Push Failure Fallback - - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} - - **Fallback Issue:** [#${issue.number}](${issue.html_url}) - - **Patch Artifact:** Available in workflow run artifacts - - **Note:** Push failed, created issue as fallback - ` - ) - .write(); - return; - } catch (issueError) { - core.setFailed( - `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` - ); - return; } } - } else { - core.info("Skipping patch application (empty patch)"); - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - try { - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Empty branch pushed successfully"); - } catch (pushError) { - core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - return; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - } else { - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to apply - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } + await core.summary.addRaw(summaryContent).write(); } + core.info(`Completed processing ${result.items.length} safe output item(s)`); + } + (async () => { try { - const { data: pullRequest } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }); - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - labels: labels, - }); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } - core.setOutput("pull_request_number", pullRequest.number); - core.setOutput("pull_request_url", pullRequest.html_url); - core.setOutput("branch_name", branchName); - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - await core.summary - .addRaw( - ` - ## Pull Request - - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - - **Branch**: \`${branchName}\` - - **Base Branch**: \`${baseBranch}\` - ` - ) - .write(); - } catch (prError) { - core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); - core.info("Falling back to creating an issue instead"); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). - **Original error:** ${prError instanceof Error ? prError.message : String(prError)} - You can manually create a pull request from the branch if needed.${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - await core.summary - .addRaw( - ` - ## Fallback Issue Created - - **Issue**: [#${issue.number}](${issue.html_url}) - - **Branch**: [\`${branchName}\`](${branchUrl}) - - **Base Branch**: \`${baseBranch}\` - - **Note**: Pull request creation failed, created issue as fallback - ` - ) - .write(); - } catch (issueError) { - core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`); - return; - } + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/intelligence.lock.yml b/.github/workflows/intelligence.lock.yml index 1e2d89c5bb..7606bcd0ed 100644 --- a/.github/workflows/intelligence.lock.yml +++ b/.github/workflows/intelligence.lock.yml @@ -7894,9 +7894,16 @@ jobs: issues: write timeout-minutes: 15 outputs: - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7909,1082 +7916,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Campaign Intelligence System" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; + summaryContent += `**Repository:** ${item.repo}\n\n`; } if (item.body) { - content += `**Body:**\n${item.body}\n\n`; + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; - } - return content; - }, - }); + summaryContent += "---\n\n"; + } + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } - } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } - } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } await core.summary.addRaw(summaryContent).write(); } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } (async () => { - await main(); - })(); - - name: Upload Assets - id: upload_assets - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'upload_asset')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Campaign Intelligence System" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const path = require("path"); - const crypto = require("crypto"); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function normalizeBranchName(branchName) { - if (!branchName || typeof branchName !== "string" || branchName.trim() === "") { - return branchName; - } - let normalized = branchName.replace(/[^a-zA-Z0-9\-_/.]+/g, "-"); - normalized = normalized.replace(/-+/g, "-"); - normalized = normalized.replace(/^-+|-+$/g, ""); - if (normalized.length > 128) { - normalized = normalized.substring(0, 128); - } - normalized = normalized.replace(/-+$/, ""); - normalized = normalized.toLowerCase(); - return normalized; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const branchName = process.env.GH_AW_ASSETS_BRANCH; - if (!branchName || typeof branchName !== "string") { - core.setFailed("GH_AW_ASSETS_BRANCH environment variable is required but not set"); - return; - } - const normalizedBranchName = normalizeBranchName(branchName); - core.info(`Using assets branch: ${normalizedBranchName}`); - const result = loadAgentOutput(); - if (!result.success) { - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - const uploadItems = result.items.filter( item => item.type === "upload_assets"); - const uploadAssetItems = result.items.filter( item => item.type === "upload_asset"); - const allUploadItems = [...uploadItems, ...uploadAssetItems]; - if (allUploadItems.length === 0) { - core.info("No upload-asset items found in agent output"); - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - core.info(`Found ${allUploadItems.length} upload-asset item(s)`); - let uploadCount = 0; - let hasChanges = false; try { - try { - await exec.exec(`git rev-parse --verify origin/${normalizedBranchName}`); - await exec.exec(`git checkout -B ${normalizedBranchName} origin/${normalizedBranchName}`); - core.info(`Checked out existing branch from origin: ${normalizedBranchName}`); - } catch (originError) { - if (!normalizedBranchName.startsWith("assets/")) { - core.setFailed( - `Branch '${normalizedBranchName}' does not start with the required 'assets/' prefix. ` + - `Orphaned branches can only be automatically created under the 'assets/' prefix. ` + - `Please create the branch manually first, or use a branch name starting with 'assets/'.` - ); - return; - } - core.info(`Creating new orphaned branch: ${normalizedBranchName}`); - await exec.exec(`git checkout --orphan ${normalizedBranchName}`); - await exec.exec(`git rm -rf .`); - await exec.exec(`git clean -fdx`); - } - for (const asset of uploadAssetItems) { - try { - const { fileName, sha, size, targetFileName } = asset; - if (!fileName || !sha || !targetFileName) { - core.error(`Invalid asset entry missing required fields: ${JSON.stringify(asset)}`); - continue; - } - const assetSourcePath = path.join("/tmp/gh-aw/safeoutputs/assets", fileName); - if (!fs.existsSync(assetSourcePath)) { - core.warning(`Asset file not found: ${assetSourcePath}`); - continue; - } - const fileContent = fs.readFileSync(assetSourcePath); - const computedSha = crypto.createHash("sha256").update(fileContent).digest("hex"); - if (computedSha !== sha) { - core.warning(`SHA mismatch for ${fileName}: expected ${sha}, got ${computedSha}`); - continue; - } - if (fs.existsSync(targetFileName)) { - core.info(`Asset ${targetFileName} already exists, skipping`); - continue; - } - fs.copyFileSync(assetSourcePath, targetFileName); - await exec.exec(`git add "${targetFileName}"`); - uploadCount++; - hasChanges = true; - core.info(`Added asset: ${targetFileName} (${size} bytes)`); - } catch (error) { - core.warning(`Failed to process asset ${asset.fileName}: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (hasChanges) { - const commitMessage = `[skip-ci] Add ${uploadCount} asset(s)`; - await exec.exec(`git`, [`commit`, `-m`, commitMessage]); - if (isStaged) { - core.summary.addRaw("## Staged Asset Publication"); - } else { - await exec.exec(`git push origin ${normalizedBranchName}`); - core.summary.addRaw("## Assets").addRaw(`Successfully uploaded **${uploadCount}** assets to branch \`${normalizedBranchName}\``).addRaw(""); - core.info(`Successfully uploaded ${uploadCount} assets to branch ${normalizedBranchName}`); - } - for (const asset of uploadAssetItems) { - if (asset.fileName && asset.sha && asset.size && asset.url) { - core.summary.addRaw(`- [\`${asset.fileName}\`](${asset.url}) → \`${asset.targetFileName}\` (${asset.size} bytes)`); - } - } - core.summary.write(); - } else { - core.info("No new assets to upload"); - } + await main(); } catch (error) { - core.setFailed(`Failed to upload assets: ${error instanceof Error ? error.message : String(error)}`); - return; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - core.setOutput("upload_count", uploadCount.toString()); - core.setOutput("branch_name", normalizedBranchName); - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/issue-arborist.lock.yml b/.github/workflows/issue-arborist.lock.yml index b8dbbaf8a8..f9d5b16599 100644 --- a/.github/workflows/issue-arborist.lock.yml +++ b/.github/workflows/issue-arborist.lock.yml @@ -6410,11 +6410,16 @@ jobs: issues: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6427,1955 +6432,178 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_ISSUE_TITLE_PREFIX: "[Parent] " - GH_AW_WORKFLOW_NAME: "Issue Arborist" - GH_AW_ENGINE_ID: "codex" - with: + GH_AW_DISCUSSION_CATEGORY: "Audits" + GH_AW_DISCUSSION_TITLE_PREFIX: "[Issue Arborist] " +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; + summaryContent += `**Repository:** ${item.repo}\n\n`; } if (item.body) { - content += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - return content; - }, - }); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; - } - } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } - } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); + summaryContent += "---\n\n"; } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; - } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; } await core.summary.addRaw(summaryContent).write(); - } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); - } - (async () => { - await main(); - })(); - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Issue Arborist" - GH_AW_ENGINE_ID: "codex" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } - async function main() { - core.setOutput("discussion_number", ""); - core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } - const result = loadAgentOutput(); - if (!result.success) { + core.info("📝 Safe outputs preview written to step summary"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; - } - summaryContent += "---\n\n"; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); - return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; - } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; - } - } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; - } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; - } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } - await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); - } - (async () => { await main(); })(); - - name: Link Sub Issue - id: link_sub_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'link_sub_issue')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_CREATED_ISSUE_NUMBER: ${{ steps.create_issue.outputs.issue_number }} - GH_AW_TEMPORARY_ID_MAP: ${{ steps.create_issue.outputs.temporary_id_map }} - GH_AW_WORKFLOW_NAME: "Issue Arborist" - GH_AW_ENGINE_ID: "codex" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { loadTemporaryIdMap, resolveIssueNumber } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - async function main() { - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const linkItems = result.items.filter(item => item.type === "link_sub_issue"); - if (linkItems.length === 0) { - core.info("No link_sub_issue items found in agent output"); - return; - } - core.info(`Found ${linkItems.length} link_sub_issue item(s)`); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - await generateStagedPreview({ - title: "Link Sub-Issue", - description: "The following sub-issue links would be created if staged mode was disabled:", - items: linkItems, - renderItem: item => { - const parentResolved = resolveIssueNumber(item.parent_issue_number, temporaryIdMap); - const subResolved = resolveIssueNumber(item.sub_issue_number, temporaryIdMap); - let parentDisplay = parentResolved.resolved ? `${parentResolved.resolved.repo}#${parentResolved.resolved.number}` : `${item.parent_issue_number} (unresolved)`; - let subDisplay = subResolved.resolved ? `${subResolved.resolved.repo}#${subResolved.resolved.number}` : `${item.sub_issue_number} (unresolved)`; - if (parentResolved.wasTemporaryId && parentResolved.resolved) { - parentDisplay += ` (from ${item.parent_issue_number})`; - } - if (subResolved.wasTemporaryId && subResolved.resolved) { - subDisplay += ` (from ${item.sub_issue_number})`; - } - let content = `**Parent Issue:** ${parentDisplay}\n`; - content += `**Sub-Issue:** ${subDisplay}\n\n`; - return content; - }, - }); - return; - } - const parentRequiredLabelsEnv = process.env.GH_AW_LINK_SUB_ISSUE_PARENT_REQUIRED_LABELS?.trim(); - const parentRequiredLabels = parentRequiredLabelsEnv - ? parentRequiredLabelsEnv - .split(",") - .map(l => l.trim()) - .filter(l => l) - : []; - const parentTitlePrefix = process.env.GH_AW_LINK_SUB_ISSUE_PARENT_TITLE_PREFIX?.trim() || ""; - const subRequiredLabelsEnv = process.env.GH_AW_LINK_SUB_ISSUE_SUB_REQUIRED_LABELS?.trim(); - const subRequiredLabels = subRequiredLabelsEnv - ? subRequiredLabelsEnv - .split(",") - .map(l => l.trim()) - .filter(l => l) - : []; - const subTitlePrefix = process.env.GH_AW_LINK_SUB_ISSUE_SUB_TITLE_PREFIX?.trim() || ""; - if (parentRequiredLabels.length > 0) { - core.info(`Parent required labels: ${JSON.stringify(parentRequiredLabels)}`); - } - if (parentTitlePrefix) { - core.info(`Parent title prefix: ${parentTitlePrefix}`); - } - if (subRequiredLabels.length > 0) { - core.info(`Sub-issue required labels: ${JSON.stringify(subRequiredLabels)}`); - } - if (subTitlePrefix) { - core.info(`Sub-issue title prefix: ${subTitlePrefix}`); - } - const maxCountEnv = process.env.GH_AW_LINK_SUB_ISSUE_MAX_COUNT; - const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 5; - if (isNaN(maxCount) || maxCount < 1) { - core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); - return; } - core.info(`Max count: ${maxCount}`); - const itemsToProcess = linkItems.slice(0, maxCount); - if (linkItems.length > maxCount) { - core.warning(`Found ${linkItems.length} link_sub_issue items, but max is ${maxCount}. Processing first ${maxCount}.`); - } - const results = []; - for (const item of itemsToProcess) { - const parentResolved = resolveIssueNumber(item.parent_issue_number, temporaryIdMap); - const subResolved = resolveIssueNumber(item.sub_issue_number, temporaryIdMap); - if (parentResolved.errorMessage) { - core.warning(`Failed to resolve parent issue: ${parentResolved.errorMessage}`); - results.push({ - parent_issue_number: item.parent_issue_number, - sub_issue_number: item.sub_issue_number, - success: false, - error: parentResolved.errorMessage, - }); - continue; - } - if (subResolved.errorMessage) { - core.warning(`Failed to resolve sub-issue: ${subResolved.errorMessage}`); - results.push({ - parent_issue_number: item.parent_issue_number, - sub_issue_number: item.sub_issue_number, - success: false, - error: subResolved.errorMessage, - }); - continue; - } - const parentIssueNumber = parentResolved.resolved.number; - const subIssueNumber = subResolved.resolved.number; - if (parentResolved.wasTemporaryId) { - core.info(`Resolved parent temporary ID '${item.parent_issue_number}' to ${parentResolved.resolved.repo}#${parentIssueNumber}`); - } - if (subResolved.wasTemporaryId) { - core.info(`Resolved sub-issue temporary ID '${item.sub_issue_number}' to ${subResolved.resolved.repo}#${subIssueNumber}`); - } - let parentIssue; - try { - const parentResponse = await github.rest.issues.get({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - }); - parentIssue = parentResponse.data; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.warning(`Failed to fetch parent issue #${parentIssueNumber}: ${errorMessage}`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: false, - error: `Failed to fetch parent issue: ${errorMessage}`, - }); - continue; - } - if (parentRequiredLabels.length > 0) { - const parentLabels = parentIssue.labels.map(l => (typeof l === "string" ? l : l.name || "")); - const missingLabels = parentRequiredLabels.filter(required => !parentLabels.includes(required)); - if (missingLabels.length > 0) { - core.warning(`Parent issue #${parentIssueNumber} is missing required labels: ${missingLabels.join(", ")}. Skipping.`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: false, - error: `Parent issue missing required labels: ${missingLabels.join(", ")}`, - }); - continue; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } } - if (parentTitlePrefix && !parentIssue.title.startsWith(parentTitlePrefix)) { - core.warning(`Parent issue #${parentIssueNumber} title does not start with "${parentTitlePrefix}". Skipping.`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: false, - error: `Parent issue title does not start with "${parentTitlePrefix}"`, - }); - continue; - } - let subIssue; - try { - const subResponse = await github.rest.issues.get({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: subIssueNumber, - }); - subIssue = subResponse.data; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to fetch sub-issue #${subIssueNumber}: ${errorMessage}`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: false, - error: `Failed to fetch sub-issue: ${errorMessage}`, - }); - continue; - } - try { - const parentCheckQuery = ` - query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $number) { - parent { - number - title - } - } - } - } - `; - const parentCheckResult = await github.graphql(parentCheckQuery, { - owner: context.repo.owner, - repo: context.repo.repo, - number: subIssueNumber, - }); - const existingParent = parentCheckResult?.repository?.issue?.parent; - if (existingParent) { - core.warning(`Sub-issue #${subIssueNumber} is already a sub-issue of #${existingParent.number} ("${existingParent.title}"). Skipping.`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: false, - error: `Sub-issue is already a sub-issue of #${existingParent.number}`, - }); - continue; - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.warning(`Could not check if sub-issue #${subIssueNumber} has a parent: ${errorMessage}. Proceeding with link attempt.`); - } - if (subRequiredLabels.length > 0) { - const subLabels = subIssue.labels.map(l => (typeof l === "string" ? l : l.name || "")); - const missingLabels = subRequiredLabels.filter(required => !subLabels.includes(required)); - if (missingLabels.length > 0) { - core.warning(`Sub-issue #${subIssueNumber} is missing required labels: ${missingLabels.join(", ")}. Skipping.`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: false, - error: `Sub-issue missing required labels: ${missingLabels.join(", ")}`, - }); - continue; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } - if (subTitlePrefix && !subIssue.title.startsWith(subTitlePrefix)) { - core.warning(`Sub-issue #${subIssueNumber} title does not start with "${subTitlePrefix}". Skipping.`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: false, - error: `Sub-issue title does not start with "${subTitlePrefix}"`, - }); - continue; - } - try { - const parentNodeId = parentIssue.node_id; - const subNodeId = subIssue.node_id; - await github.graphql( - ` - mutation AddSubIssue($parentId: ID!, $subIssueId: ID!) { - addSubIssue(input: { issueId: $parentId, subIssueId: $subIssueId }) { - issue { - id - number - } - subIssue { - id - number - } - } - } - `, - { - parentId: parentNodeId, - subIssueId: subNodeId, - } - ); - core.info(`Successfully linked issue #${subIssueNumber} as sub-issue of #${parentIssueNumber}`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: true, - }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.warning(`Failed to link issue #${subIssueNumber} as sub-issue of #${parentIssueNumber}: ${errorMessage}`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: false, - error: errorMessage, - }); - } - } - const successCount = results.filter(r => r.success).length; - const failureCount = results.filter(r => !r.success).length; - let summaryContent = "## Link Sub-Issue\n\n"; - if (successCount > 0) { - summaryContent += `✅ Successfully linked ${successCount} sub-issue(s):\n\n`; - for (const result of results.filter(r => r.success)) { - summaryContent += `- Issue #${result.sub_issue_number} → Parent #${result.parent_issue_number}\n`; - } - summaryContent += "\n"; - } - if (failureCount > 0) { - summaryContent += `⚠️ Failed to link ${failureCount} sub-issue(s):\n\n`; - for (const result of results.filter(r => !r.success)) { - summaryContent += `- Issue #${result.sub_issue_number} → Parent #${result.parent_issue_number}: ${result.error}\n`; - } - } - await core.summary.addRaw(summaryContent).write(); - const linkedIssues = results - .filter(r => r.success) - .map(r => `${r.parent_issue_number}:${r.sub_issue_number}`) - .join("\n"); - core.setOutput("linked_issues", linkedIssues); - if (failureCount > 0) { - core.warning(`Failed to link ${failureCount} sub-issue(s). See step summary for details.`); + await core.summary.addRaw(summaryContent).write(); } + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); diff --git a/.github/workflows/issue-classifier.lock.yml b/.github/workflows/issue-classifier.lock.yml index 162568ad7d..d772739f50 100644 --- a/.github/workflows/issue-classifier.lock.yml +++ b/.github/workflows/issue-classifier.lock.yml @@ -5372,10 +5372,18 @@ jobs: permissions: contents: read issues: write - pull-requests: write timeout-minutes: 15 outputs: - add_labels_labels_added: ${{ steps.add_labels.outputs.labels_added }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -5388,895 +5396,175 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/safe_output_helpers.cjs << 'EOF_80a143d8' - // @ts-check - /// - - /** - * Shared helper functions for safe-output scripts - * Provides common validation and target resolution logic - */ - - /** - * Parse a comma-separated list of allowed items from environment variable - * @param {string|undefined} envValue - Environment variable value - * @returns {string[]|undefined} Array of allowed items, or undefined if no restrictions - */ - function parseAllowedItems(envValue) { - const trimmed = envValue?.trim(); - if (!trimmed) { - return undefined; - } - return trimmed - .split(",") - .map(item => item.trim()) - .filter(item => item); - } - - /** - * Parse and validate max count from environment variable - * @param {string|undefined} envValue - Environment variable value - * @param {number} defaultValue - Default value if not specified - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function parseMaxCount(envValue, defaultValue = 3) { - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - /** - * Resolve the target number (issue/PR) based on configuration and context - * @param {Object} params - Resolution parameters - * @param {string} params.targetConfig - Target configuration ("triggering", "*", or explicit number) - * @param {any} params.item - Safe output item with optional item_number or pull_request_number - * @param {any} params.context - GitHub Actions context - * @param {string} params.itemType - Type of item being processed (for error messages) - * @param {boolean} params.supportsPR - Whether this safe output supports PR context - * @returns {{success: true, number: number, contextType: string} | {success: false, error: string, shouldFail: boolean}} Resolution result - */ - function resolveTarget(params) { - const { targetConfig, item, context, itemType, supportsPR = false } = params; - - // Check context type - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - - // Default target is "triggering" - const target = targetConfig || "triggering"; - - // Validate context for triggering mode - if (target === "triggering") { - if (supportsPR) { - if (!isIssueContext && !isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in issue or pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } else { - if (!isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } - } - - // Resolve target number - let itemNumber; - let contextType; - - if (target === "*") { - // Use item_number, issue_number, or pull_request_number from item - const numberField = supportsPR ? item.item_number || item.issue_number || item.pull_request_number : item.pull_request_number; - - if (numberField) { - itemNumber = typeof numberField === "number" ? numberField : parseInt(String(numberField), 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "item_number/issue_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, - shouldFail: true, - }; - } - contextType = supportsPR && (item.item_number || item.issue_number) ? "issue" : "pull request"; - } else { - return { - success: false, - error: `Target is "*" but no ${supportsPR ? "item_number/issue_number" : "pull_request_number"} specified in ${itemType} item`, - shouldFail: true, - }; - } - } else if (target !== "triggering") { - // Explicit number - itemNumber = parseInt(target, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "issue" : "pull request"} number in target configuration: ${target}`, - shouldFail: true, - }; - } - contextType = supportsPR ? "issue" : "pull request"; - } else { - // Use triggering context - if (isIssueContext) { - if (context.payload.issue) { - itemNumber = context.payload.issue.number; - contextType = "issue"; - } else { - return { - success: false, - error: "Issue context detected but no issue found in payload", - shouldFail: true, - }; - } - } else if (isPRContext) { - if (context.payload.pull_request) { - itemNumber = context.payload.pull_request.number; - contextType = "pull request"; - } else { - return { - success: false, - error: "Pull request context detected but no pull request found in payload", - shouldFail: true, - }; - } - } - } - - if (!itemNumber) { - return { - success: false, - error: `Could not determine ${supportsPR ? "issue or pull request" : "pull request"} number`, - shouldFail: true, - }; - } - - return { - success: true, - number: itemNumber, - contextType: contextType || (supportsPR ? "issue" : "pull request"), - }; - } - - module.exports = { - parseAllowedItems, - parseMaxCount, - resolveTarget, - }; - - EOF_80a143d8 - cat > /tmp/gh-aw/scripts/safe_output_processor.cjs << 'EOF_8f3864e2' - // @ts-check - /// - - /** - * Shared processor for safe-output scripts - * Provides common pipeline: load agent output, handle staged mode, parse config, resolve target - */ - - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { parseAllowedItems, resolveTarget } = require('/tmp/gh-aw/scripts/safe_output_helpers.cjs'); - const { getSafeOutputConfig, validateMaxCount } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs'); - - /** - * @typedef {Object} ProcessorConfig - * @property {string} itemType - The type field value to match in agent output (e.g., "add_labels") - * @property {string} configKey - The key to use when reading from config.json (e.g., "add_labels") - * @property {string} displayName - Human-readable name for logging (e.g., "Add Labels") - * @property {string} itemTypeName - Name used in error messages (e.g., "label addition") - * @property {boolean} [supportsPR] - When true, allows both issue AND PR contexts; when false, only PR context (default: false) - * @property {boolean} [supportsIssue] - When true, passes supportsPR=true to resolveTarget to enable both contexts (default: false) - * @property {boolean} [findMultiple] - Whether to find multiple items instead of just one (default: false) - * @property {Object} envVars - Environment variable names - * @property {string} [envVars.allowed] - Env var for allowed items list - * @property {string} [envVars.maxCount] - Env var for max count - * @property {string} [envVars.target] - Env var for target configuration - */ - - /** - * @typedef {Object} ProcessorResult - * @property {boolean} success - Whether processing should continue - * @property {any} [item] - The found item (when findMultiple is false) - * @property {any[]} [items] - The found items (when findMultiple is true) - * @property {Object} [config] - Parsed configuration - * @property {string[]|undefined} [config.allowed] - Allowed items list - * @property {number} [config.maxCount] - Maximum count - * @property {string} [config.target] - Target configuration - * @property {Object} [targetResult] - Result from resolveTarget (when findMultiple is false) - * @property {number} [targetResult.number] - Target issue/PR number - * @property {string} [targetResult.contextType] - Type of context (issue or pull request) - * @property {string} [reason] - Reason why processing should not continue - */ - - /** - * Process the initial steps common to safe-output scripts: - * 1. Load agent output - * 2. Find matching item(s) - * 3. Handle staged mode - * 4. Parse configuration - * 5. Resolve target (for single-item processors) - * - * @param {ProcessorConfig} config - Processor configuration - * @param {Object} stagedPreviewOptions - Options for staged preview - * @param {string} stagedPreviewOptions.title - Title for staged preview - * @param {string} stagedPreviewOptions.description - Description for staged preview - * @param {(item: any, index: number) => string} stagedPreviewOptions.renderItem - Function to render item in preview - * @returns {Promise} Processing result - */ - async function processSafeOutput(config, stagedPreviewOptions) { - const { itemType, configKey, displayName, itemTypeName, supportsPR = false, supportsIssue = false, findMultiple = false, envVars } = config; - - // Step 1: Load agent output - const result = loadAgentOutput(); - if (!result.success) { - return { success: false, reason: "Agent output not available" }; - } - - // Step 2: Find matching item(s) - let items; - if (findMultiple) { - items = result.items.filter(item => item.type === itemType); - if (items.length === 0) { - core.info(`No ${itemType} items found in agent output`); - return { success: false, reason: `No ${itemType} items found` }; - } - core.info(`Found ${items.length} ${itemType} item(s)`); - } else { - const item = result.items.find(item => item.type === itemType); - if (!item) { - core.warning(`No ${itemType.replace(/_/g, "-")} item found in agent output`); - return { success: false, reason: `No ${itemType} item found` }; - } - items = [item]; - // Log item details based on common fields - const itemDetails = getItemDetails(item); - if (itemDetails) { - core.info(`Found ${itemType.replace(/_/g, "-")} item with ${itemDetails}`); - } - } - - // Step 3: Handle staged mode - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - await generateStagedPreview({ - title: stagedPreviewOptions.title, - description: stagedPreviewOptions.description, - items: items, - renderItem: stagedPreviewOptions.renderItem, - }); - return { success: false, reason: "Staged mode - preview generated" }; - } - - // Step 4: Parse configuration - const safeOutputConfig = getSafeOutputConfig(configKey); - - // Parse allowed items (from env or config) - const allowedEnvValue = envVars.allowed ? process.env[envVars.allowed] : undefined; - const allowed = parseAllowedItems(allowedEnvValue) || safeOutputConfig.allowed; - if (allowed) { - core.info(`Allowed ${itemTypeName}s: ${JSON.stringify(allowed)}`); - } else { - core.info(`No ${itemTypeName} restrictions - any ${itemTypeName}s are allowed`); - } - - // Parse max count (env takes priority, then config) - const maxCountEnvValue = envVars.maxCount ? process.env[envVars.maxCount] : undefined; - const maxCountResult = validateMaxCount(maxCountEnvValue, safeOutputConfig.max); - if (!maxCountResult.valid) { - core.setFailed(maxCountResult.error); - return { success: false, reason: "Invalid max count configuration" }; - } - const maxCount = maxCountResult.value; - core.info(`Max count: ${maxCount}`); - - // Get target configuration - const target = envVars.target ? process.env[envVars.target] || "triggering" : "triggering"; - core.info(`${displayName} target configuration: ${target}`); - - // For multiple items, return early without target resolution - if (findMultiple) { - return { - success: true, - items: items, - config: { - allowed, - maxCount, - target, - }, - }; - } - - // Step 5: Resolve target (for single-item processors) - const item = items[0]; - const targetResult = resolveTarget({ - targetConfig: target, - item: item, - context, - itemType: itemTypeName, - // supportsPR in resolveTarget: true=both issue and PR contexts, false=PR-only - // If supportsIssue is true, we pass supportsPR=true to enable both contexts - supportsPR: supportsPR || supportsIssue, - }); - - if (!targetResult.success) { - if (targetResult.shouldFail) { - core.setFailed(targetResult.error); - } else { - core.info(targetResult.error); - } - return { success: false, reason: targetResult.error }; - } - - return { - success: true, - item: item, - config: { - allowed, - maxCount, - target, - }, - targetResult: { - number: targetResult.number, - contextType: targetResult.contextType, - }, - }; - } - - /** - * Get a description of item details for logging - * @param {any} item - The safe output item - * @returns {string|null} Description string or null - */ - function getItemDetails(item) { - if (item.labels && Array.isArray(item.labels)) { - return `${item.labels.length} labels`; - } - if (item.reviewers && Array.isArray(item.reviewers)) { - return `${item.reviewers.length} reviewers`; - } - return null; - } - - /** - * Sanitize and deduplicate an array of string items - * @param {any[]} items - Raw items array - * @returns {string[]} Sanitized and deduplicated array - */ - function sanitizeItems(items) { - return items - .filter(item => item != null && item !== false && item !== 0) - .map(item => String(item).trim()) - .filter(item => item) - .filter((item, index, arr) => arr.indexOf(item) === index); - } - - /** - * Filter items by allowed list - * @param {string[]} items - Items to filter - * @param {string[]|undefined} allowed - Allowed items list (undefined means all allowed) - * @returns {string[]} Filtered items - */ - function filterByAllowed(items, allowed) { - if (!allowed || allowed.length === 0) { - return items; - } - return items.filter(item => allowed.includes(item)); - } - - /** - * Limit items to max count - * @param {string[]} items - Items to limit - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Limited items - */ - function limitToMaxCount(items, maxCount) { - if (items.length > maxCount) { - core.info(`Too many items (${items.length}), limiting to ${maxCount}`); - return items.slice(0, maxCount); - } - return items; - } - - /** - * Process items through the standard pipeline: filter by allowed, sanitize, dedupe, limit - * @param {any[]} rawItems - Raw items array from agent output - * @param {string[]|undefined} allowed - Allowed items list - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Processed items - */ - function processItems(rawItems, allowed, maxCount) { - // Filter by allowed list first - const filtered = filterByAllowed(rawItems, allowed); - - // Sanitize and deduplicate - const sanitized = sanitizeItems(filtered); - - // Limit to max count - return limitToMaxCount(sanitized, maxCount); - } - - module.exports = { - processSafeOutput, - sanitizeItems, - filterByAllowed, - limitToMaxCount, - processItems, - }; - - EOF_8f3864e2 - cat > /tmp/gh-aw/scripts/safe_output_validator.cjs << 'EOF_437e6b4f' - // @ts-check - /// - - const fs = require("fs"); - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - - /** - * Load and parse the safe outputs configuration from config.json - * @returns {object} The parsed configuration object - */ - function loadSafeOutputsConfig() { - const configPath = "/tmp/gh-aw/safeoutputs/config.json"; - try { - if (!fs.existsSync(configPath)) { - core.warning(`Config file not found at ${configPath}, using defaults`); - return {}; - } - const configContent = fs.readFileSync(configPath, "utf8"); - return JSON.parse(configContent); - } catch (error) { - core.warning(`Failed to load config: ${error instanceof Error ? error.message : String(error)}`); - return {}; - } - } - - /** - * Get configuration for a specific safe output type - * @param {string} outputType - The type of safe output (e.g., "add_labels", "update_issue") - * @returns {{max?: number, target?: string, allowed?: string[]}} The configuration for this output type - */ - function getSafeOutputConfig(outputType) { - const config = loadSafeOutputsConfig(); - return config[outputType] || {}; - } - - /** - * Validate and sanitize a title string - * @param {any} title - The title to validate - * @param {string} fieldName - The name of the field for error messages (default: "title") - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateTitle(title, fieldName = "title") { - if (title === undefined || title === null) { - return { valid: false, error: `${fieldName} is required` }; - } - - if (typeof title !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - const trimmed = title.trim(); - if (trimmed.length === 0) { - return { valid: false, error: `${fieldName} cannot be empty` }; - } - - return { valid: true, value: trimmed }; - } - - /** - * Validate and sanitize a body/content string - * @param {any} body - The body to validate - * @param {string} fieldName - The name of the field for error messages (default: "body") - * @param {boolean} required - Whether the body is required (default: false) - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateBody(body, fieldName = "body", required = false) { - if (body === undefined || body === null) { - if (required) { - return { valid: false, error: `${fieldName} is required` }; - } - return { valid: true, value: "" }; - } - - if (typeof body !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - return { valid: true, value: body }; - } - - /** - * Validate and sanitize an array of labels - * @param {any} labels - The labels to validate - * @param {string[]|undefined} allowedLabels - Optional list of allowed labels - * @param {number} maxCount - Maximum number of labels allowed - * @returns {{valid: boolean, value?: string[], error?: string}} Validation result - */ - function validateLabels(labels, allowedLabels = undefined, maxCount = 3) { - if (!labels || !Array.isArray(labels)) { - return { valid: false, error: "labels must be an array" }; - } - - // Check for removal attempts (labels starting with '-') - for (const label of labels) { - if (label && typeof label === "string" && label.startsWith("-")) { - return { valid: false, error: `Label removal is not permitted. Found line starting with '-': ${label}` }; - } - } - - // Filter labels based on allowed list if provided - let validLabels = labels; - if (allowedLabels && allowedLabels.length > 0) { - validLabels = labels.filter(label => allowedLabels.includes(label)); - } - - // Sanitize and deduplicate labels - const uniqueLabels = validLabels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - - // Apply max count limit - if (uniqueLabels.length > maxCount) { - core.info(`Too many labels (${uniqueLabels.length}), limiting to ${maxCount}`); - return { valid: true, value: uniqueLabels.slice(0, maxCount) }; - } - - if (uniqueLabels.length === 0) { - return { valid: false, error: "No valid labels found after sanitization" }; - } - - return { valid: true, value: uniqueLabels }; - } - - /** - * Validate max count from environment variable with config fallback - * @param {string|undefined} envValue - Environment variable value - * @param {number|undefined} configDefault - Default from config.json - * @param {number} [fallbackDefault] - Fallback default for testing (optional, defaults to 1) - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function validateMaxCount(envValue, configDefault, fallbackDefault = 1) { - // Priority: env var > config.json > fallback default - // In production, config.json should always have the default - // Fallback is provided for backward compatibility and testing - const defaultValue = configDefault !== undefined ? configDefault : fallbackDefault; - - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - module.exports = { - loadSafeOutputsConfig, - getSafeOutputConfig, - validateTitle, - validateBody, - validateLabels, - validateMaxCount, - }; - - EOF_437e6b4f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - - name: Add Labels - id: add_labels - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_labels')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_LABELS_ALLOWED: "bug,feature,enhancement,documentation" - GH_AW_LABELS_MAX_COUNT: 1 - GH_AW_WORKFLOW_NAME: "Issue Classifier" - GH_AW_ENGINE_ID: "custom" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { processSafeOutput } = require('/tmp/gh-aw/scripts/safe_output_processor.cjs'); - const { validateLabels } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { - const result = await processSafeOutput( - { - itemType: "add_labels", - configKey: "add_labels", - displayName: "Labels", - itemTypeName: "label addition", - supportsPR: true, - supportsIssue: true, - envVars: { - allowed: "GH_AW_LABELS_ALLOWED", - maxCount: "GH_AW_LABELS_MAX_COUNT", - target: "GH_AW_LABELS_TARGET", - }, - }, - { - title: "Add Labels", - description: "The following labels would be added if staged mode was disabled:", - renderItem: item => { - let content = ""; - if (item.item_number) { - content += `**Target Issue:** #${item.item_number}\n\n`; - } else { - content += `**Target:** Current issue/PR\n\n`; + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); + return; + } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - if (item.labels && item.labels.length > 0) { - content += `**Labels to add:** ${item.labels.join(", ")}\n\n`; + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } - return content; - }, + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; + } } - ); - if (!result.success) { + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - const { item: labelsItem, config, targetResult } = result; - if (!config || !targetResult || targetResult.number === undefined) { - core.setFailed("Internal error: config, targetResult, or targetResult.number is undefined"); - return; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const { allowed: allowedLabels, maxCount } = config; - const itemNumber = targetResult.number; - const { contextType } = targetResult; - const requestedLabels = labelsItem.labels || []; - core.info(`Requested labels: ${JSON.stringify(requestedLabels)}`); - const labelsResult = validateLabels(requestedLabels, allowedLabels, maxCount); - if (!labelsResult.valid) { - if (labelsResult.error && labelsResult.error.includes("No valid labels")) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` - ## Label Addition - No labels were added (no valid labels found in agent output). - ` - ) - .write(); - return; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; + } + } } - core.setFailed(labelsResult.error || "Invalid labels"); - return; } - const uniqueLabels = labelsResult.value || []; - if (uniqueLabels.length === 0) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` - ## Label Addition - No labels were added (no valid labels found in agent output). - ` - ) - .write(); - return; + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } + } + await core.summary.addRaw(summaryContent).write(); } - core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${itemNumber}: ${JSON.stringify(uniqueLabels)}`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); + } + (async () => { try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - labels: uniqueLabels, - }); - core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${itemNumber}`); - core.setOutput("labels_added", uniqueLabels.join("\n")); - const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); - await core.summary - .addRaw( - ` - ## Label Addition - Successfully added ${uniqueLabels.length} label(s) to ${contextType} #${itemNumber}: - ${labelsListMarkdown} - ` - ) - .write(); + await main(); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to add labels: ${errorMessage}`); - core.setFailed(`Failed to add labels: ${errorMessage}`); + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); diff --git a/.github/workflows/issue-monster.lock.yml b/.github/workflows/issue-monster.lock.yml index 403fcc8ae1..11f92bcae3 100644 --- a/.github/workflows/issue-monster.lock.yml +++ b/.github/workflows/issue-monster.lock.yml @@ -6892,9 +6892,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} - assign_to_agent_assigned: ${{ steps.assign_to_agent.outputs.assigned }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6907,1661 +6914,176 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/assign_agent_helpers.cjs << 'EOF_b5665d23' - // @ts-check - /// - - /** - * Shared helper functions for assigning coding agents (like Copilot) to issues - * These functions use GraphQL to properly assign bot actors that cannot be assigned via gh CLI - * - * NOTE: All functions use the built-in `github` global object for authentication. - * The token must be set at the step level via the `github-token` parameter in GitHub Actions. - * This approach is required for compatibility with actions/github-script@v8. - */ - - /** - * Map agent names to their GitHub bot login names - * @type {Record} - */ - const AGENT_LOGIN_NAMES = { - copilot: "copilot-swe-agent", - }; - - /** - * Check if an assignee is a known coding agent (bot) - * @param {string} assignee - Assignee name (may include @ prefix) - * @returns {string|null} Agent name if it's a known agent, null otherwise - */ - function getAgentName(assignee) { - // Normalize: remove @ prefix if present - const normalized = assignee.startsWith("@") ? assignee.slice(1) : assignee; - - // Check if it's a known agent - if (AGENT_LOGIN_NAMES[normalized]) { - return normalized; - } - - return null; - } - - /** - * Return list of coding agent bot login names that are currently available as assignable actors - * (intersection of suggestedActors and known AGENT_LOGIN_NAMES values) - * @param {string} owner - * @param {string} repo - * @returns {Promise} - */ - async function getAvailableAgentLogins(owner, repo) { - const query = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - suggestedActors(first: 100, capabilities: CAN_BE_ASSIGNED) { - nodes { ... on Bot { login __typename } } - } - } - } - `; - try { - const response = await github.graphql(query, { owner, repo }); - const actors = response.repository?.suggestedActors?.nodes || []; - const knownValues = Object.values(AGENT_LOGIN_NAMES); - const available = []; - for (const actor of actors) { - if (actor && actor.login && knownValues.includes(actor.login)) { - available.push(actor.login); - } - } - return available.sort(); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - core.debug(`Failed to list available agent logins: ${msg}`); - return []; - } - } - - /** - * Find an agent in repository's suggested actors using GraphQL - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} agentName - Agent name (copilot) - * @returns {Promise} Agent ID or null if not found - */ - async function findAgent(owner, repo, agentName) { - const query = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - suggestedActors(first: 100, capabilities: CAN_BE_ASSIGNED) { - nodes { - ... on Bot { - id - login - __typename - } - } - } - } - } - `; - - try { - const response = await github.graphql(query, { owner, repo }); - const actors = response.repository.suggestedActors.nodes; - - const loginName = AGENT_LOGIN_NAMES[agentName]; - if (!loginName) { - core.error(`Unknown agent: ${agentName}. Supported agents: ${Object.keys(AGENT_LOGIN_NAMES).join(", ")}`); - return null; - } - - for (const actor of actors) { - if (actor.login === loginName) { - return actor.id; - } - } - - const available = actors.filter(a => a && a.login && Object.values(AGENT_LOGIN_NAMES).includes(a.login)).map(a => a.login); - - core.warning(`${agentName} coding agent (${loginName}) is not available as an assignee for this repository`); - if (available.length > 0) { - core.info(`Available assignable coding agents: ${available.join(", ")}`); - } else { - core.info("No coding agents are currently assignable in this repository."); - } - if (agentName === "copilot") { - core.info("Please visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot"); - } - return null; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to find ${agentName} agent: ${errorMessage}`); - return null; - } - } - - /** - * Get issue details (ID and current assignees) using GraphQL - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {number} issueNumber - Issue number - * @returns {Promise<{issueId: string, currentAssignees: string[]}|null>} - */ - async function getIssueDetails(owner, repo, issueNumber) { - const query = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - assignees(first: 100) { - nodes { - id - } - } - } - } - } - `; - - try { - const response = await github.graphql(query, { owner, repo, issueNumber }); - const issue = response.repository.issue; - - if (!issue || !issue.id) { - core.error("Could not get issue data"); - return null; - } - - const currentAssignees = issue.assignees.nodes.map(assignee => assignee.id); - - return { - issueId: issue.id, - currentAssignees: currentAssignees, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to get issue details: ${errorMessage}`); - return null; - } - } - - /** - * Assign agent to issue using GraphQL replaceActorsForAssignable mutation - * @param {string} issueId - GitHub issue ID - * @param {string} agentId - Agent ID - * @param {string[]} currentAssignees - List of current assignee IDs - * @param {string} agentName - Agent name for error messages - * @returns {Promise} True if successful - */ - async function assignAgentToIssue(issueId, agentId, currentAssignees, agentName) { - // Build actor IDs array - include agent and preserve other assignees - const actorIds = [agentId]; - for (const assigneeId of currentAssignees) { - if (assigneeId !== agentId) { - actorIds.push(assigneeId); - } - } - - const mutation = ` - mutation($assignableId: ID!, $actorIds: [ID!]!) { - replaceActorsForAssignable(input: { - assignableId: $assignableId, - actorIds: $actorIds - }) { - __typename - } - } - `; - - try { - core.info("Using built-in github object for mutation"); - - core.debug(`GraphQL mutation with variables: assignableId=${issueId}, actorIds=${JSON.stringify(actorIds)}`); - const response = await github.graphql(mutation, { - assignableId: issueId, - actorIds: actorIds, - }); - - if (response && response.replaceActorsForAssignable && response.replaceActorsForAssignable.__typename) { - return true; - } else { - core.error("Unexpected response from GitHub API"); - return false; - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - - // Debug: surface the raw GraphQL error structure for troubleshooting fine-grained permission issues - try { - core.debug(`Raw GraphQL error message: ${errorMessage}`); - if (error && typeof error === "object") { - // Common GraphQL error shapes: error.errors (array), error.data, error.response - const details = {}; - if (error.errors) details.errors = error.errors; - // Some libraries wrap the payload under 'response' or 'response.data' - if (error.response) details.response = error.response; - if (error.data) details.data = error.data; - // If GitHub returns an array of errors with 'type'/'message' - if (Array.isArray(error.errors)) { - details.compactMessages = error.errors.map(e => e.message).filter(Boolean); - } - const serialized = JSON.stringify(details, (_k, v) => v, 2); - if (serialized && serialized !== "{}") { - core.debug(`Raw GraphQL error details: ${serialized}`); - // Also emit non-debug version so users without ACTIONS_STEP_DEBUG can see it - core.error("Raw GraphQL error details (for troubleshooting):"); - // Split large JSON for readability - for (const line of serialized.split(/\n/)) { - if (line.trim()) core.error(line); - } - } - } - } catch (loggingErr) { - // Never fail assignment because of debug logging - core.debug(`Failed to serialize GraphQL error details: ${loggingErr instanceof Error ? loggingErr.message : String(loggingErr)}`); - } - - // Check for permission-related errors - if (errorMessage.includes("Resource not accessible by personal access token") || errorMessage.includes("Resource not accessible by integration") || errorMessage.includes("Insufficient permissions to assign")) { - // Attempt fallback mutation addAssigneesToAssignable when replaceActorsForAssignable is forbidden - core.info("Primary mutation replaceActorsForAssignable forbidden. Attempting fallback addAssigneesToAssignable..."); - try { - const fallbackMutation = ` - mutation($assignableId: ID!, $assigneeIds: [ID!]!) { - addAssigneesToAssignable(input: { - assignableId: $assignableId, - assigneeIds: $assigneeIds - }) { - clientMutationId - } - } - `; - core.info("Using built-in github object for fallback mutation"); - core.debug(`Fallback GraphQL mutation with variables: assignableId=${issueId}, assigneeIds=[${agentId}]`); - const fallbackResp = await github.graphql(fallbackMutation, { - assignableId: issueId, - assigneeIds: [agentId], - }); - if (fallbackResp && fallbackResp.addAssigneesToAssignable) { - core.info(`Fallback succeeded: agent '${agentName}' added via addAssigneesToAssignable.`); - return true; - } else { - core.warning("Fallback mutation returned unexpected response; proceeding with permission guidance."); - } - } catch (fallbackError) { - const fbMsg = fallbackError instanceof Error ? fallbackError.message : String(fallbackError); - core.error(`Fallback addAssigneesToAssignable failed: ${fbMsg}`); - } - logPermissionError(agentName); - } else { - core.error(`Failed to assign ${agentName}: ${errorMessage}`); - } - return false; - } - } - - /** - * Log detailed permission error guidance - * @param {string} agentName - Agent name for error messages - */ - function logPermissionError(agentName) { - core.error(`Failed to assign ${agentName}: Insufficient permissions`); - core.error(""); - core.error("Assigning Copilot agents requires:"); - core.error(" 1. All four workflow permissions:"); - core.error(" - actions: write"); - core.error(" - contents: write"); - core.error(" - issues: write"); - core.error(" - pull-requests: write"); - core.error(""); - core.error(" 2. A classic PAT with 'repo' scope OR fine-grained PAT with explicit Write permissions above:"); - core.error(" (Fine-grained PATs must grant repository access + write for Issues, Pull requests, Contents, Actions)"); - core.error(""); - core.error(" 3. Repository settings:"); - core.error(" - Actions must have write permissions"); - core.error(" - Go to: Settings > Actions > General > Workflow permissions"); - core.error(" - Select: 'Read and write permissions'"); - core.error(""); - core.error(" 4. Organization/Enterprise settings:"); - core.error(" - Check if your org restricts bot assignments"); - core.error(" - Verify Copilot is enabled for your repository"); - core.error(""); - core.info("For more information, see: https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/create-a-pr"); - } - - /** - * Generate permission error summary content for step summary - * @returns {string} Markdown content for permission error guidance - */ - function generatePermissionErrorSummary() { - let content = "\n### ⚠️ Permission Requirements\n\n"; - content += "Assigning Copilot agents requires **ALL** of these permissions:\n\n"; - content += "```yaml\n"; - content += "permissions:\n"; - content += " actions: write\n"; - content += " contents: write\n"; - content += " issues: write\n"; - content += " pull-requests: write\n"; - content += "```\n\n"; - content += "**Token capability note:**\n"; - content += "- Current token (PAT or GITHUB_TOKEN) lacks assignee mutation capability for this repository.\n"; - content += "- Both `replaceActorsForAssignable` and fallback `addAssigneesToAssignable` returned FORBIDDEN/Resource not accessible.\n"; - content += "- This typically means bot/user assignment requires an elevated OAuth or GitHub App installation token.\n\n"; - content += "**Recommended remediation paths:**\n"; - content += "1. Create & install a GitHub App with: Issues/Pull requests/Contents/Actions (write) → use installation token in job.\n"; - content += "2. Manual assignment: add the agent through the UI until broader token support is available.\n"; - content += "3. Open a support ticket referencing failing mutation `replaceActorsForAssignable` and repository slug.\n\n"; - content += "**Why this failed:** Fine-grained and classic PATs can update issue title (verified) but not modify assignees in this environment.\n\n"; - content += "📖 Reference: https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/create-a-pr (general agent docs)\n"; - return content; - } - - /** - * Assign an agent to an issue using GraphQL - * This is the main entry point for assigning agents from other scripts - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {number} issueNumber - Issue number - * @param {string} agentName - Agent name (e.g., "copilot") - * @returns {Promise<{success: boolean, error?: string}>} - */ - async function assignAgentToIssueByName(owner, repo, issueNumber, agentName) { - // Check if agent is supported - if (!AGENT_LOGIN_NAMES[agentName]) { - const error = `Agent "${agentName}" is not supported. Supported agents: ${Object.keys(AGENT_LOGIN_NAMES).join(", ")}`; - core.warning(error); - return { success: false, error }; - } - - try { - // Find agent using the github object authenticated via step-level github-token - core.info(`Looking for ${agentName} coding agent...`); - const agentId = await findAgent(owner, repo, agentName); - if (!agentId) { - const error = `${agentName} coding agent is not available for this repository`; - // Enrich with available agent logins - const available = await getAvailableAgentLogins(owner, repo); - const enrichedError = available.length > 0 ? `${error} (available agents: ${available.join(", ")})` : error; - return { success: false, error: enrichedError }; - } - core.info(`Found ${agentName} coding agent (ID: ${agentId})`); - - // Get issue details (ID and current assignees) via GraphQL - core.info("Getting issue details..."); - const issueDetails = await getIssueDetails(owner, repo, issueNumber); - if (!issueDetails) { - return { success: false, error: "Failed to get issue details" }; - } - - core.info(`Issue ID: ${issueDetails.issueId}`); - - // Check if agent is already assigned - if (issueDetails.currentAssignees.includes(agentId)) { - core.info(`${agentName} is already assigned to issue #${issueNumber}`); - return { success: true }; - } - - // Assign agent using GraphQL mutation - core.info(`Assigning ${agentName} coding agent to issue #${issueNumber}...`); - const success = await assignAgentToIssue(issueDetails.issueId, agentId, issueDetails.currentAssignees, agentName); - - if (!success) { - return { success: false, error: `Failed to assign ${agentName} via GraphQL` }; - } - - core.info(`Successfully assigned ${agentName} coding agent to issue #${issueNumber}`); - return { success: true }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { success: false, error: errorMessage }; - } - } - - module.exports = { - AGENT_LOGIN_NAMES, - getAgentName, - getAvailableAgentLogins, - findAgent, - getIssueDetails, - assignAgentToIssue, - logPermissionError, - generatePermissionErrorSummary, - assignAgentToIssueByName, - }; - - EOF_b5665d23 - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Issue Monster" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🍪 *Om nom nom by [{workflow_name}]({run_url})*\",\"runStarted\":\"🍪 ISSUE! ISSUE! [{workflow_name}]({run_url}) hungry for issues on this {event_type}! Om nom nom...\",\"runSuccess\":\"🍪 YUMMY! [{workflow_name}]({run_url}) ate the issues! That was DELICIOUS! Me want MORE! 😋\",\"runFailure\":\"🍪 Aww... [{workflow_name}]({run_url}) {status}. No cookie for monster today... 😢\"}" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } - } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; - } - } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); - return; - } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; - } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } + summaryContent += "---\n\n"; } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; - } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); - } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); - } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); - } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; - } - (async () => { await main(); })(); - - name: Assign To Agent - id: assign_to_agent - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'assign_to_agent')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Issue Monster" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🍪 *Om nom nom by [{workflow_name}]({run_url})*\",\"runStarted\":\"🍪 ISSUE! ISSUE! [{workflow_name}]({run_url}) hungry for issues on this {event_type}! Om nom nom...\",\"runSuccess\":\"🍪 YUMMY! [{workflow_name}]({run_url}) ate the issues! That was DELICIOUS! Me want MORE! 😋\",\"runFailure\":\"🍪 Aww... [{workflow_name}]({run_url}) {status}. No cookie for monster today... 😢\"}" - with: - github-token: ${{ secrets.GH_AW_AGENT_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { AGENT_LOGIN_NAMES, getAvailableAgentLogins, findAgent, getIssueDetails, assignAgentToIssue, generatePermissionErrorSummary } = require('/tmp/gh-aw/scripts/assign_agent_helpers.cjs'); - async function main() { - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const assignItems = result.items.filter(item => item.type === "assign_to_agent"); - if (assignItems.length === 0) { - core.info("No assign_to_agent items found in agent output"); + core.info("📝 Safe outputs preview written to step summary"); return; } - core.info(`Found ${assignItems.length} assign_to_agent item(s)`); - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - await generateStagedPreview({ - title: "Assign to Agent", - description: "The following agent assignments would be made if staged mode was disabled:", - items: assignItems, - renderItem: item => { - let content = `**Issue:** #${item.issue_number}\n`; - content += `**Agent:** ${item.agent || "copilot"}\n`; - content += "\n"; - return content; - }, - }); - return; - } - const defaultAgent = process.env.GH_AW_AGENT_DEFAULT?.trim() || "copilot"; - core.info(`Default agent: ${defaultAgent}`); - const maxCountEnv = process.env.GH_AW_AGENT_MAX_COUNT; - const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 1; - if (isNaN(maxCount) || maxCount < 1) { - core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); - return; - } - core.info(`Max count: ${maxCount}`); - const itemsToProcess = assignItems.slice(0, maxCount); - if (assignItems.length > maxCount) { - core.warning(`Found ${assignItems.length} agent assignments, but max is ${maxCount}. Processing first ${maxCount}.`); - } - const targetRepoEnv = process.env.GH_AW_TARGET_REPO?.trim(); - let targetOwner = context.repo.owner; - let targetRepo = context.repo.repo; - if (targetRepoEnv) { - const parts = targetRepoEnv.split("/"); - if (parts.length === 2) { - targetOwner = parts[0]; - targetRepo = parts[1]; - core.info(`Using target repository: ${targetOwner}/${targetRepo}`); - } else { - core.warning(`Invalid target-repo format: ${targetRepoEnv}. Expected owner/repo. Using current repository.`); - } + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const agentCache = {}; - const results = []; - for (const item of itemsToProcess) { - const issueNumber = typeof item.issue_number === "number" ? item.issue_number : parseInt(String(item.issue_number), 10); - const agentName = item.agent || defaultAgent; - if (isNaN(issueNumber) || issueNumber <= 0) { - core.error(`Invalid issue_number: ${item.issue_number}`); - continue; - } - if (!AGENT_LOGIN_NAMES[agentName]) { - core.warning(`Agent "${agentName}" is not supported. Supported agents: ${Object.keys(AGENT_LOGIN_NAMES).join(", ")}`); - results.push({ - issue_number: issueNumber, - agent: agentName, - success: false, - error: `Unsupported agent: ${agentName}`, - }); - continue; - } - try { - let agentId = agentCache[agentName]; - if (!agentId) { - core.info(`Looking for ${agentName} coding agent...`); - agentId = await findAgent(targetOwner, targetRepo, agentName); - if (!agentId) { - throw new Error(`${agentName} coding agent is not available for this repository`); - } - agentCache[agentName] = agentId; - core.info(`Found ${agentName} coding agent (ID: ${agentId})`); - } - core.info("Getting issue details..."); - const issueDetails = await getIssueDetails(targetOwner, targetRepo, issueNumber); - if (!issueDetails) { - throw new Error("Failed to get issue details"); - } - core.info(`Issue ID: ${issueDetails.issueId}`); - if (issueDetails.currentAssignees.includes(agentId)) { - core.info(`${agentName} is already assigned to issue #${issueNumber}`); - results.push({ - issue_number: issueNumber, - agent: agentName, - success: true, - }); - continue; - } - core.info(`Assigning ${agentName} coding agent to issue #${issueNumber}...`); - const success = await assignAgentToIssue(issueDetails.issueId, agentId, issueDetails.currentAssignees, agentName); - if (!success) { - throw new Error(`Failed to assign ${agentName} via GraphQL`); - } - core.info(`Successfully assigned ${agentName} coding agent to issue #${issueNumber}`); - results.push({ - issue_number: issueNumber, - agent: agentName, - success: true, - }); - } catch (error) { - let errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("coding agent is not available for this repository")) { - try { - const available = await getAvailableAgentLogins(targetOwner, targetRepo); - if (available.length > 0) { - errorMessage += ` (available agents: ${available.join(", ")})`; - } - } catch (e) { - core.debug("Failed to enrich unavailable agent message with available list"); + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } - core.error(`Failed to assign agent "${agentName}" to issue #${issueNumber}: ${errorMessage}`); - results.push({ - issue_number: issueNumber, - agent: agentName, - success: false, - error: errorMessage, - }); } } - const successCount = results.filter(r => r.success).length; - const failureCount = results.filter(r => !r.success).length; - let summaryContent = "## Agent Assignment\n\n"; - if (successCount > 0) { - summaryContent += `✅ Successfully assigned ${successCount} agent(s):\n\n`; - for (const result of results.filter(r => r.success)) { - summaryContent += `- Issue #${result.issue_number} → Agent: ${result.agent}\n`; + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } - summaryContent += "\n"; } - if (failureCount > 0) { - summaryContent += `❌ Failed to assign ${failureCount} agent(s):\n\n`; - for (const result of results.filter(r => !r.success)) { - summaryContent += `- Issue #${result.issue_number} → Agent: ${result.agent}: ${result.error}\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - const hasPermissionError = results.some(r => !r.success && r.error && (r.error.includes("Resource not accessible") || r.error.includes("Insufficient permissions"))); - if (hasPermissionError) { - summaryContent += generatePermissionErrorSummary(); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } + await core.summary.addRaw(summaryContent).write(); } - await core.summary.addRaw(summaryContent).write(); - const assignedAgents = results - .filter(r => r.success) - .map(r => `${r.issue_number}:${r.agent}`) - .join("\n"); - core.setOutput("assigned_agents", assignedAgents); - if (failureCount > 0) { - core.setFailed(`Failed to assign ${failureCount} agent(s)`); - } + core.info(`Completed processing ${result.items.length} safe output item(s)`); } (async () => { - await main(); + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } })(); search_issues: diff --git a/.github/workflows/issue-triage-agent.lock.yml b/.github/workflows/issue-triage-agent.lock.yml index faa91b19a1..7eb3ac9050 100644 --- a/.github/workflows/issue-triage-agent.lock.yml +++ b/.github/workflows/issue-triage-agent.lock.yml @@ -6454,9 +6454,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} - add_labels_labels_added: ${{ steps.add_labels.outputs.labels_added }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6469,1810 +6476,175 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/safe_output_helpers.cjs << 'EOF_80a143d8' - // @ts-check - /// - - /** - * Shared helper functions for safe-output scripts - * Provides common validation and target resolution logic - */ - - /** - * Parse a comma-separated list of allowed items from environment variable - * @param {string|undefined} envValue - Environment variable value - * @returns {string[]|undefined} Array of allowed items, or undefined if no restrictions - */ - function parseAllowedItems(envValue) { - const trimmed = envValue?.trim(); - if (!trimmed) { - return undefined; - } - return trimmed - .split(",") - .map(item => item.trim()) - .filter(item => item); - } - - /** - * Parse and validate max count from environment variable - * @param {string|undefined} envValue - Environment variable value - * @param {number} defaultValue - Default value if not specified - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function parseMaxCount(envValue, defaultValue = 3) { - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - /** - * Resolve the target number (issue/PR) based on configuration and context - * @param {Object} params - Resolution parameters - * @param {string} params.targetConfig - Target configuration ("triggering", "*", or explicit number) - * @param {any} params.item - Safe output item with optional item_number or pull_request_number - * @param {any} params.context - GitHub Actions context - * @param {string} params.itemType - Type of item being processed (for error messages) - * @param {boolean} params.supportsPR - Whether this safe output supports PR context - * @returns {{success: true, number: number, contextType: string} | {success: false, error: string, shouldFail: boolean}} Resolution result - */ - function resolveTarget(params) { - const { targetConfig, item, context, itemType, supportsPR = false } = params; - - // Check context type - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - - // Default target is "triggering" - const target = targetConfig || "triggering"; - - // Validate context for triggering mode - if (target === "triggering") { - if (supportsPR) { - if (!isIssueContext && !isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in issue or pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } else { - if (!isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } - } - - // Resolve target number - let itemNumber; - let contextType; - - if (target === "*") { - // Use item_number, issue_number, or pull_request_number from item - const numberField = supportsPR ? item.item_number || item.issue_number || item.pull_request_number : item.pull_request_number; - - if (numberField) { - itemNumber = typeof numberField === "number" ? numberField : parseInt(String(numberField), 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "item_number/issue_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, - shouldFail: true, - }; - } - contextType = supportsPR && (item.item_number || item.issue_number) ? "issue" : "pull request"; - } else { - return { - success: false, - error: `Target is "*" but no ${supportsPR ? "item_number/issue_number" : "pull_request_number"} specified in ${itemType} item`, - shouldFail: true, - }; - } - } else if (target !== "triggering") { - // Explicit number - itemNumber = parseInt(target, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "issue" : "pull request"} number in target configuration: ${target}`, - shouldFail: true, - }; - } - contextType = supportsPR ? "issue" : "pull request"; - } else { - // Use triggering context - if (isIssueContext) { - if (context.payload.issue) { - itemNumber = context.payload.issue.number; - contextType = "issue"; - } else { - return { - success: false, - error: "Issue context detected but no issue found in payload", - shouldFail: true, - }; - } - } else if (isPRContext) { - if (context.payload.pull_request) { - itemNumber = context.payload.pull_request.number; - contextType = "pull request"; - } else { - return { - success: false, - error: "Pull request context detected but no pull request found in payload", - shouldFail: true, - }; - } - } - } - - if (!itemNumber) { - return { - success: false, - error: `Could not determine ${supportsPR ? "issue or pull request" : "pull request"} number`, - shouldFail: true, - }; - } - - return { - success: true, - number: itemNumber, - contextType: contextType || (supportsPR ? "issue" : "pull request"), - }; - } - - module.exports = { - parseAllowedItems, - parseMaxCount, - resolveTarget, - }; - - EOF_80a143d8 - cat > /tmp/gh-aw/scripts/safe_output_processor.cjs << 'EOF_8f3864e2' - // @ts-check - /// - - /** - * Shared processor for safe-output scripts - * Provides common pipeline: load agent output, handle staged mode, parse config, resolve target - */ - - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { parseAllowedItems, resolveTarget } = require('/tmp/gh-aw/scripts/safe_output_helpers.cjs'); - const { getSafeOutputConfig, validateMaxCount } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs'); - - /** - * @typedef {Object} ProcessorConfig - * @property {string} itemType - The type field value to match in agent output (e.g., "add_labels") - * @property {string} configKey - The key to use when reading from config.json (e.g., "add_labels") - * @property {string} displayName - Human-readable name for logging (e.g., "Add Labels") - * @property {string} itemTypeName - Name used in error messages (e.g., "label addition") - * @property {boolean} [supportsPR] - When true, allows both issue AND PR contexts; when false, only PR context (default: false) - * @property {boolean} [supportsIssue] - When true, passes supportsPR=true to resolveTarget to enable both contexts (default: false) - * @property {boolean} [findMultiple] - Whether to find multiple items instead of just one (default: false) - * @property {Object} envVars - Environment variable names - * @property {string} [envVars.allowed] - Env var for allowed items list - * @property {string} [envVars.maxCount] - Env var for max count - * @property {string} [envVars.target] - Env var for target configuration - */ - - /** - * @typedef {Object} ProcessorResult - * @property {boolean} success - Whether processing should continue - * @property {any} [item] - The found item (when findMultiple is false) - * @property {any[]} [items] - The found items (when findMultiple is true) - * @property {Object} [config] - Parsed configuration - * @property {string[]|undefined} [config.allowed] - Allowed items list - * @property {number} [config.maxCount] - Maximum count - * @property {string} [config.target] - Target configuration - * @property {Object} [targetResult] - Result from resolveTarget (when findMultiple is false) - * @property {number} [targetResult.number] - Target issue/PR number - * @property {string} [targetResult.contextType] - Type of context (issue or pull request) - * @property {string} [reason] - Reason why processing should not continue - */ - - /** - * Process the initial steps common to safe-output scripts: - * 1. Load agent output - * 2. Find matching item(s) - * 3. Handle staged mode - * 4. Parse configuration - * 5. Resolve target (for single-item processors) - * - * @param {ProcessorConfig} config - Processor configuration - * @param {Object} stagedPreviewOptions - Options for staged preview - * @param {string} stagedPreviewOptions.title - Title for staged preview - * @param {string} stagedPreviewOptions.description - Description for staged preview - * @param {(item: any, index: number) => string} stagedPreviewOptions.renderItem - Function to render item in preview - * @returns {Promise} Processing result - */ - async function processSafeOutput(config, stagedPreviewOptions) { - const { itemType, configKey, displayName, itemTypeName, supportsPR = false, supportsIssue = false, findMultiple = false, envVars } = config; - - // Step 1: Load agent output - const result = loadAgentOutput(); - if (!result.success) { - return { success: false, reason: "Agent output not available" }; - } - - // Step 2: Find matching item(s) - let items; - if (findMultiple) { - items = result.items.filter(item => item.type === itemType); - if (items.length === 0) { - core.info(`No ${itemType} items found in agent output`); - return { success: false, reason: `No ${itemType} items found` }; - } - core.info(`Found ${items.length} ${itemType} item(s)`); - } else { - const item = result.items.find(item => item.type === itemType); - if (!item) { - core.warning(`No ${itemType.replace(/_/g, "-")} item found in agent output`); - return { success: false, reason: `No ${itemType} item found` }; - } - items = [item]; - // Log item details based on common fields - const itemDetails = getItemDetails(item); - if (itemDetails) { - core.info(`Found ${itemType.replace(/_/g, "-")} item with ${itemDetails}`); - } - } - - // Step 3: Handle staged mode - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - await generateStagedPreview({ - title: stagedPreviewOptions.title, - description: stagedPreviewOptions.description, - items: items, - renderItem: stagedPreviewOptions.renderItem, - }); - return { success: false, reason: "Staged mode - preview generated" }; - } - - // Step 4: Parse configuration - const safeOutputConfig = getSafeOutputConfig(configKey); - - // Parse allowed items (from env or config) - const allowedEnvValue = envVars.allowed ? process.env[envVars.allowed] : undefined; - const allowed = parseAllowedItems(allowedEnvValue) || safeOutputConfig.allowed; - if (allowed) { - core.info(`Allowed ${itemTypeName}s: ${JSON.stringify(allowed)}`); - } else { - core.info(`No ${itemTypeName} restrictions - any ${itemTypeName}s are allowed`); - } - - // Parse max count (env takes priority, then config) - const maxCountEnvValue = envVars.maxCount ? process.env[envVars.maxCount] : undefined; - const maxCountResult = validateMaxCount(maxCountEnvValue, safeOutputConfig.max); - if (!maxCountResult.valid) { - core.setFailed(maxCountResult.error); - return { success: false, reason: "Invalid max count configuration" }; - } - const maxCount = maxCountResult.value; - core.info(`Max count: ${maxCount}`); - - // Get target configuration - const target = envVars.target ? process.env[envVars.target] || "triggering" : "triggering"; - core.info(`${displayName} target configuration: ${target}`); - - // For multiple items, return early without target resolution - if (findMultiple) { - return { - success: true, - items: items, - config: { - allowed, - maxCount, - target, - }, - }; - } - - // Step 5: Resolve target (for single-item processors) - const item = items[0]; - const targetResult = resolveTarget({ - targetConfig: target, - item: item, - context, - itemType: itemTypeName, - // supportsPR in resolveTarget: true=both issue and PR contexts, false=PR-only - // If supportsIssue is true, we pass supportsPR=true to enable both contexts - supportsPR: supportsPR || supportsIssue, - }); - - if (!targetResult.success) { - if (targetResult.shouldFail) { - core.setFailed(targetResult.error); - } else { - core.info(targetResult.error); - } - return { success: false, reason: targetResult.error }; - } - - return { - success: true, - item: item, - config: { - allowed, - maxCount, - target, - }, - targetResult: { - number: targetResult.number, - contextType: targetResult.contextType, - }, - }; - } - - /** - * Get a description of item details for logging - * @param {any} item - The safe output item - * @returns {string|null} Description string or null - */ - function getItemDetails(item) { - if (item.labels && Array.isArray(item.labels)) { - return `${item.labels.length} labels`; - } - if (item.reviewers && Array.isArray(item.reviewers)) { - return `${item.reviewers.length} reviewers`; - } - return null; - } - - /** - * Sanitize and deduplicate an array of string items - * @param {any[]} items - Raw items array - * @returns {string[]} Sanitized and deduplicated array - */ - function sanitizeItems(items) { - return items - .filter(item => item != null && item !== false && item !== 0) - .map(item => String(item).trim()) - .filter(item => item) - .filter((item, index, arr) => arr.indexOf(item) === index); - } - - /** - * Filter items by allowed list - * @param {string[]} items - Items to filter - * @param {string[]|undefined} allowed - Allowed items list (undefined means all allowed) - * @returns {string[]} Filtered items - */ - function filterByAllowed(items, allowed) { - if (!allowed || allowed.length === 0) { - return items; - } - return items.filter(item => allowed.includes(item)); - } - - /** - * Limit items to max count - * @param {string[]} items - Items to limit - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Limited items - */ - function limitToMaxCount(items, maxCount) { - if (items.length > maxCount) { - core.info(`Too many items (${items.length}), limiting to ${maxCount}`); - return items.slice(0, maxCount); - } - return items; - } - - /** - * Process items through the standard pipeline: filter by allowed, sanitize, dedupe, limit - * @param {any[]} rawItems - Raw items array from agent output - * @param {string[]|undefined} allowed - Allowed items list - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Processed items - */ - function processItems(rawItems, allowed, maxCount) { - // Filter by allowed list first - const filtered = filterByAllowed(rawItems, allowed); - - // Sanitize and deduplicate - const sanitized = sanitizeItems(filtered); - - // Limit to max count - return limitToMaxCount(sanitized, maxCount); - } - - module.exports = { - processSafeOutput, - sanitizeItems, - filterByAllowed, - limitToMaxCount, - processItems, - }; - - EOF_8f3864e2 - cat > /tmp/gh-aw/scripts/safe_output_validator.cjs << 'EOF_437e6b4f' - // @ts-check - /// - - const fs = require("fs"); - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - - /** - * Load and parse the safe outputs configuration from config.json - * @returns {object} The parsed configuration object - */ - function loadSafeOutputsConfig() { - const configPath = "/tmp/gh-aw/safeoutputs/config.json"; - try { - if (!fs.existsSync(configPath)) { - core.warning(`Config file not found at ${configPath}, using defaults`); - return {}; - } - const configContent = fs.readFileSync(configPath, "utf8"); - return JSON.parse(configContent); - } catch (error) { - core.warning(`Failed to load config: ${error instanceof Error ? error.message : String(error)}`); - return {}; - } - } - - /** - * Get configuration for a specific safe output type - * @param {string} outputType - The type of safe output (e.g., "add_labels", "update_issue") - * @returns {{max?: number, target?: string, allowed?: string[]}} The configuration for this output type - */ - function getSafeOutputConfig(outputType) { - const config = loadSafeOutputsConfig(); - return config[outputType] || {}; - } - - /** - * Validate and sanitize a title string - * @param {any} title - The title to validate - * @param {string} fieldName - The name of the field for error messages (default: "title") - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateTitle(title, fieldName = "title") { - if (title === undefined || title === null) { - return { valid: false, error: `${fieldName} is required` }; - } - - if (typeof title !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - const trimmed = title.trim(); - if (trimmed.length === 0) { - return { valid: false, error: `${fieldName} cannot be empty` }; - } - - return { valid: true, value: trimmed }; - } - - /** - * Validate and sanitize a body/content string - * @param {any} body - The body to validate - * @param {string} fieldName - The name of the field for error messages (default: "body") - * @param {boolean} required - Whether the body is required (default: false) - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateBody(body, fieldName = "body", required = false) { - if (body === undefined || body === null) { - if (required) { - return { valid: false, error: `${fieldName} is required` }; - } - return { valid: true, value: "" }; - } - - if (typeof body !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - return { valid: true, value: body }; - } - - /** - * Validate and sanitize an array of labels - * @param {any} labels - The labels to validate - * @param {string[]|undefined} allowedLabels - Optional list of allowed labels - * @param {number} maxCount - Maximum number of labels allowed - * @returns {{valid: boolean, value?: string[], error?: string}} Validation result - */ - function validateLabels(labels, allowedLabels = undefined, maxCount = 3) { - if (!labels || !Array.isArray(labels)) { - return { valid: false, error: "labels must be an array" }; - } - - // Check for removal attempts (labels starting with '-') - for (const label of labels) { - if (label && typeof label === "string" && label.startsWith("-")) { - return { valid: false, error: `Label removal is not permitted. Found line starting with '-': ${label}` }; - } - } - - // Filter labels based on allowed list if provided - let validLabels = labels; - if (allowedLabels && allowedLabels.length > 0) { - validLabels = labels.filter(label => allowedLabels.includes(label)); - } - - // Sanitize and deduplicate labels - const uniqueLabels = validLabels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - - // Apply max count limit - if (uniqueLabels.length > maxCount) { - core.info(`Too many labels (${uniqueLabels.length}), limiting to ${maxCount}`); - return { valid: true, value: uniqueLabels.slice(0, maxCount) }; - } - - if (uniqueLabels.length === 0) { - return { valid: false, error: "No valid labels found after sanitization" }; - } - - return { valid: true, value: uniqueLabels }; - } - - /** - * Validate max count from environment variable with config fallback - * @param {string|undefined} envValue - Environment variable value - * @param {number|undefined} configDefault - Default from config.json - * @param {number} [fallbackDefault] - Fallback default for testing (optional, defaults to 1) - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function validateMaxCount(envValue, configDefault, fallbackDefault = 1) { - // Priority: env var > config.json > fallback default - // In production, config.json should always have the default - // Fallback is provided for backward compatibility and testing - const defaultValue = configDefault !== undefined ? configDefault : fallbackDefault; - - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - module.exports = { - loadSafeOutputsConfig, - getSafeOutputConfig, - validateTitle, - validateBody, - validateLabels, - validateMaxCount, - }; - - EOF_437e6b4f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Issue Triage Agent" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } - } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); - - name: Add Labels - id: add_labels - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_labels')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_LABELS_ALLOWED: "bug,feature,enhancement,documentation,question,help-wanted,good-first-issue" - GH_AW_WORKFLOW_NAME: "Issue Triage Agent" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { processSafeOutput } = require('/tmp/gh-aw/scripts/safe_output_processor.cjs'); - const { validateLabels } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs'); - async function main() { - const result = await processSafeOutput( - { - itemType: "add_labels", - configKey: "add_labels", - displayName: "Labels", - itemTypeName: "label addition", - supportsPR: true, - supportsIssue: true, - envVars: { - allowed: "GH_AW_LABELS_ALLOWED", - maxCount: "GH_AW_LABELS_MAX_COUNT", - target: "GH_AW_LABELS_TARGET", - }, - }, - { - title: "Add Labels", - description: "The following labels would be added if staged mode was disabled:", - renderItem: item => { - let content = ""; - if (item.item_number) { - content += `**Target Issue:** #${item.item_number}\n\n`; - } else { - content += `**Target:** Current issue/PR\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels to add:** ${item.labels.join(", ")}\n\n`; - } - return content; - }, - } - ); - if (!result.success) { - return; - } - const { item: labelsItem, config, targetResult } = result; - if (!config || !targetResult || targetResult.number === undefined) { - core.setFailed("Internal error: config, targetResult, or targetResult.number is undefined"); - return; - } - const { allowed: allowedLabels, maxCount } = config; - const itemNumber = targetResult.number; - const { contextType } = targetResult; - const requestedLabels = labelsItem.labels || []; - core.info(`Requested labels: ${JSON.stringify(requestedLabels)}`); - const labelsResult = validateLabels(requestedLabels, allowedLabels, maxCount); - if (!labelsResult.valid) { - if (labelsResult.error && labelsResult.error.includes("No valid labels")) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` - ## Label Addition - No labels were added (no valid labels found in agent output). - ` - ) - .write(); - return; - } - core.setFailed(labelsResult.error || "Invalid labels"); - return; - } - const uniqueLabels = labelsResult.value || []; - if (uniqueLabels.length === 0) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` - ## Label Addition - No labels were added (no valid labels found in agent output). - ` - ) - .write(); - return; - } - core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${itemNumber}: ${JSON.stringify(uniqueLabels)}`); + (async () => { try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - labels: uniqueLabels, - }); - core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${itemNumber}`); - core.setOutput("labels_added", uniqueLabels.join("\n")); - const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); - await core.summary - .addRaw( - ` - ## Label Addition - Successfully added ${uniqueLabels.length} label(s) to ${contextType} #${itemNumber}: - ${labelsListMarkdown} - ` - ) - .write(); + await main(); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to add labels: ${errorMessage}`); - core.setFailed(`Failed to add labels: ${errorMessage}`); + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); diff --git a/.github/workflows/jsweep.lock.yml b/.github/workflows/jsweep.lock.yml index ae946aa45f..b278386083 100644 --- a/.github/workflows/jsweep.lock.yml +++ b/.github/workflows/jsweep.lock.yml @@ -6746,8 +6746,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6760,777 +6768,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Create Pull Request - id: create_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PR_TITLE_PREFIX: "[jsweep] " GH_AW_PR_LABELS: "unbloat,automation" - GH_AW_PR_DRAFT: "false" - GH_AW_PR_IF_NO_CHANGES: "ignore" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "jsweep - JavaScript Unbloater" - GH_AW_TRACKER_ID: "jsweep-daily" - GH_AW_ENGINE_ID: "copilot" - with: + GH_AW_PR_TITLE_PREFIX: "[jsweep] " +GH_AW_PR_DRAFT=falseGH_AW_PR_IF_NO_CHANGES=ignore with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { - core.setOutput("pull_request_number", ""); - core.setOutput("pull_request_url", ""); core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); - core.setOutput("branch_name", ""); - core.setOutput("fallback_used", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const workflowId = process.env.GH_AW_WORKFLOW_ID; - if (!workflowId) { - throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); - } - const baseBranch = process.env.GH_AW_BASE_BRANCH; - if (!baseBranch) { - throw new Error("GH_AW_BASE_BRANCH environment variable is required"); - } - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - let outputContent = ""; - if (agentOutputFile.trim() !== "") { - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - } - const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn"; - const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - let patchContent = ""; - let isEmpty = true; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } - if (patchContent.includes("Failed to generate patch")) { - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return; - } - throw new Error(message); - } - core.info("Patch size validation passed"); - } - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to push - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); return; } - core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - if (!title) { - title = "Agent Output"; - } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - const labelsEnv = process.env.GH_AW_PR_LABELS; - const labels = labelsEnv - ? labelsEnv - .split(",") - .map( label => label.trim()) - .filter( label => label) - : []; - const draftEnv = process.env.GH_AW_PR_DRAFT; - const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); - const randomHex = crypto.randomBytes(8).toString("hex"); - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); } else { - branchName = `${branchName}-${randomHex}`; - core.info(`Using branch name from JSONL with added salt: ${branchName}`); - } - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); - core.info(`Fetching latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${baseBranch}`); - core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`); - await exec.exec(`git checkout -b ${branchName}`); - core.info(`Created new branch from base: ${branchName}`); - if (!isEmpty) { - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - try { - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - try { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Changes pushed to branch"); - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - > [!NOTE] - > This was originally intended as a pull request, but the git push operation failed. - > - > **Workflow Run:** [View run details and download patch artifact](${runUrl}) - > - > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above. - To apply the patch locally: - \`\`\`sh - # Download the artifact from the workflow run ${runUrl} - # (Use GitHub MCP tools if gh CLI is not available) - gh run download ${runId} -n aw.patch - # Apply the patch - git am aw.patch - \`\`\` - ${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - core.setOutput("push_failed", "true"); - await core.summary - .addRaw( - ` - ## Push Failure Fallback - - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} - - **Fallback Issue:** [#${issue.number}](${issue.html_url}) - - **Patch Artifact:** Available in workflow run artifacts - - **Note:** Push failed, created issue as fallback - ` - ) - .write(); - return; - } catch (issueError) { - core.setFailed( - `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` - ); - return; } } - } else { - core.info("Skipping patch application (empty patch)"); - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - try { - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Empty branch pushed successfully"); - } catch (pushError) { - core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - return; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - } else { - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to apply - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } + await core.summary.addRaw(summaryContent).write(); } + core.info(`Completed processing ${result.items.length} safe output item(s)`); + } + (async () => { try { - const { data: pullRequest } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }); - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - labels: labels, - }); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } - core.setOutput("pull_request_number", pullRequest.number); - core.setOutput("pull_request_url", pullRequest.html_url); - core.setOutput("branch_name", branchName); - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - await core.summary - .addRaw( - ` - ## Pull Request - - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - - **Branch**: \`${branchName}\` - - **Base Branch**: \`${baseBranch}\` - ` - ) - .write(); - } catch (prError) { - core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); - core.info("Falling back to creating an issue instead"); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). - **Original error:** ${prError instanceof Error ? prError.message : String(prError)} - You can manually create a pull request from the branch if needed.${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - await core.summary - .addRaw( - ` - ## Fallback Issue Created - - **Issue**: [#${issue.number}](${issue.html_url}) - - **Branch**: [\`${branchName}\`](${branchUrl}) - - **Base Branch**: \`${baseBranch}\` - - **Note**: Pull request creation failed, created issue as fallback - ` - ) - .write(); - } catch (issueError) { - core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`); - return; - } + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/layout-spec-maintainer.lock.yml b/.github/workflows/layout-spec-maintainer.lock.yml index 94f39dfac6..9e77c3395b 100644 --- a/.github/workflows/layout-spec-maintainer.lock.yml +++ b/.github/workflows/layout-spec-maintainer.lock.yml @@ -6766,8 +6766,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6780,774 +6788,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Create Pull Request - id: create_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PR_TITLE_PREFIX: "[specs] " GH_AW_PR_LABELS: "documentation,automation" - GH_AW_PR_DRAFT: "false" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Layout Specification Maintainer" - GH_AW_TRACKER_ID: "layout-spec-maintainer" - GH_AW_ENGINE_ID: "copilot" - with: + GH_AW_PR_TITLE_PREFIX: "[specs] " +GH_AW_PR_DRAFT=false with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { - core.setOutput("pull_request_number", ""); - core.setOutput("pull_request_url", ""); core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); - core.setOutput("branch_name", ""); - core.setOutput("fallback_used", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const workflowId = process.env.GH_AW_WORKFLOW_ID; - if (!workflowId) { - throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); - } - const baseBranch = process.env.GH_AW_BASE_BRANCH; - if (!baseBranch) { - throw new Error("GH_AW_BASE_BRANCH environment variable is required"); - } - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - let outputContent = ""; - if (agentOutputFile.trim() !== "") { - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - } - const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn"; - const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - let patchContent = ""; - let isEmpty = true; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } - if (patchContent.includes("Failed to generate patch")) { - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return; - } - throw new Error(message); - } - core.info("Patch size validation passed"); - } - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to push - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); return; } - core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - if (!title) { - title = "Agent Output"; - } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - const labelsEnv = process.env.GH_AW_PR_LABELS; - const labels = labelsEnv - ? labelsEnv - .split(",") - .map( label => label.trim()) - .filter( label => label) - : []; - const draftEnv = process.env.GH_AW_PR_DRAFT; - const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); - const randomHex = crypto.randomBytes(8).toString("hex"); - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); } else { - branchName = `${branchName}-${randomHex}`; - core.info(`Using branch name from JSONL with added salt: ${branchName}`); - } - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); - core.info(`Fetching latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${baseBranch}`); - core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`); - await exec.exec(`git checkout -b ${branchName}`); - core.info(`Created new branch from base: ${branchName}`); - if (!isEmpty) { - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - try { - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - try { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Changes pushed to branch"); - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - > [!NOTE] - > This was originally intended as a pull request, but the git push operation failed. - > - > **Workflow Run:** [View run details and download patch artifact](${runUrl}) - > - > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above. - To apply the patch locally: - \`\`\`sh - # Download the artifact from the workflow run ${runUrl} - # (Use GitHub MCP tools if gh CLI is not available) - gh run download ${runId} -n aw.patch - # Apply the patch - git am aw.patch - \`\`\` - ${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - core.setOutput("push_failed", "true"); - await core.summary - .addRaw( - ` - ## Push Failure Fallback - - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} - - **Fallback Issue:** [#${issue.number}](${issue.html_url}) - - **Patch Artifact:** Available in workflow run artifacts - - **Note:** Push failed, created issue as fallback - ` - ) - .write(); - return; - } catch (issueError) { - core.setFailed( - `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` - ); - return; } } - } else { - core.info("Skipping patch application (empty patch)"); - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - try { - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Empty branch pushed successfully"); - } catch (pushError) { - core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - return; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - } else { - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to apply - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } + await core.summary.addRaw(summaryContent).write(); } + core.info(`Completed processing ${result.items.length} safe output item(s)`); + } + (async () => { try { - const { data: pullRequest } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }); - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - labels: labels, - }); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } - core.setOutput("pull_request_number", pullRequest.number); - core.setOutput("pull_request_url", pullRequest.html_url); - core.setOutput("branch_name", branchName); - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - await core.summary - .addRaw( - ` - ## Pull Request - - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - - **Branch**: \`${branchName}\` - - **Base Branch**: \`${baseBranch}\` - ` - ) - .write(); - } catch (prError) { - core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); - core.info("Falling back to creating an issue instead"); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). - **Original error:** ${prError instanceof Error ? prError.message : String(prError)} - You can manually create a pull request from the branch if needed.${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - await core.summary - .addRaw( - ` - ## Fallback Issue Created - - **Issue**: [#${issue.number}](${issue.html_url}) - - **Branch**: [\`${branchName}\`](${branchUrl}) - - **Base Branch**: \`${baseBranch}\` - - **Note**: Pull request creation failed, created issue as fallback - ` - ) - .write(); - } catch (issueError) { - core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`); - return; - } + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); diff --git a/.github/workflows/lockfile-stats.lock.yml b/.github/workflows/lockfile-stats.lock.yml index 9d6aa02b96..a4c05ff9d1 100644 --- a/.github/workflows/lockfile-stats.lock.yml +++ b/.github/workflows/lockfile-stats.lock.yml @@ -6522,8 +6522,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6536,1173 +6544,178 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Lockfile Statistics Analysis Agent" - GH_AW_ENGINE_ID: "claude" - with: + GH_AW_DISCUSSION_CATEGORY: "audits" +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; - } - } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); } } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); update_cache_memory: needs: diff --git a/.github/workflows/mcp-inspector.lock.yml b/.github/workflows/mcp-inspector.lock.yml index 93cb2fa9b7..d34efc0a83 100644 --- a/.github/workflows/mcp-inspector.lock.yml +++ b/.github/workflows/mcp-inspector.lock.yml @@ -7403,8 +7403,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7417,1173 +7425,178 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "MCP Inspector Agent" - GH_AW_ENGINE_ID: "copilot" - with: + GH_AW_DISCUSSION_CATEGORY: "audits" +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; } } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); update_cache_memory: needs: diff --git a/.github/workflows/mergefest.lock.yml b/.github/workflows/mergefest.lock.yml index 763427f32c..0136977421 100644 --- a/.github/workflows/mergefest.lock.yml +++ b/.github/workflows/mergefest.lock.yml @@ -7457,7 +7457,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - push_to_pull_request_branch_commit_url: ${{ steps.push_to_pull_request_branch.outputs.commit_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7470,522 +7479,175 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Push To Pull Request Branch - id: push_to_pull_request_branch - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'push_to_pull_request_branch')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PUSH_IF_NO_CHANGES: "warn" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Mergefest" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { updateActivationCommentWithCommit } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - if (agentOutputFile.trim() === "") { - core.info("Agent output content is empty"); - return; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - const target = process.env.GH_AW_PUSH_TARGET || "triggering"; - const ifNoChanges = process.env.GH_AW_PUSH_IF_NO_CHANGES || "warn"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - const message = "No patch file found - cannot push without changes"; - switch (ifNoChanges) { - case "error": - core.setFailed(message); - return; - case "ignore": - return; - case "warn": - default: - core.info(message); - return; - } - } - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchContent.includes("Failed to generate patch")) { - const message = "Patch file contains error message - cannot push without changes"; - core.error("Patch file generation failed - this is an error condition that requires investigation"); - core.error(`Patch file location: /tmp/gh-aw/aw.patch`); - core.error(`Patch file size: ${Buffer.byteLength(patchContent, "utf8")} bytes`); - const previewLength = Math.min(500, patchContent.length); - core.error(`Patch file preview (first ${previewLength} characters):`); - core.error(patchContent.substring(0, previewLength)); - core.setFailed(message); - return; - } - const isEmpty = !patchContent || !patchContent.trim(); - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - core.setFailed(message); - return; - } - core.info("Patch size validation passed"); - } - if (isEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - core.setFailed("No changes to push - failing as configured by if-no-changes: error"); - return; - case "ignore": - break; - case "warn": - default: - core.info(message); - break; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } - core.info(`Target configuration: ${target}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; - } - const pushItem = validatedOutput.items.find( item => item.type === "push_to_pull_request_branch"); - if (!pushItem) { - core.info("No push-to-pull-request-branch item found in agent output"); + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); return; } - core.info("Found push-to-pull-request-branch item"); + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Push to PR Branch", - description: "The following changes would be pushed if staged mode was disabled:", - items: [{ target, commit_message: pushItem.commit_message }], - renderItem: item => { - let content = ""; - content += `**Target:** ${item.target}\n\n`; - if (item.commit_message) { - content += `**Commit Message:** ${item.commit_message}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - content += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - content += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - content += `**Changes:** No changes (empty patch)\n\n`; - } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } - return content; - }, - }); + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; + } + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - if (target !== "*" && target !== "triggering") { - const pullNumber = parseInt(target, 10); - if (isNaN(pullNumber)) { - core.setFailed('Invalid target configuration: must be "triggering", "*", or a valid pull request number'); - return; - } + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - let pullNumber; - if (target === "triggering") { - pullNumber = context.payload?.pull_request?.number || context.payload?.issue?.number; - if (!pullNumber) { - core.setFailed('push-to-pull-request-branch with target "triggering" requires pull request context'); - return; - } - } else if (target === "*") { - if (pushItem.pull_number) { - pullNumber = parseInt(pushItem.pull_number, 10); - } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); } else { - pullNumber = parseInt(target, 10); - } - let branchName; - let prTitle = ""; - let prLabels = []; - try { - const prInfoRes = await exec.getExecOutput(`gh`, [`pr`, `view`, `${pullNumber}`, `--json`, `headRefName,title,labels`, `--jq`, `{headRefName, title, labels: (.labels // [] | map(.name))}`]); - if (prInfoRes.exitCode === 0) { - const prData = JSON.parse(prInfoRes.stdout.trim()); - branchName = prData.headRefName; - prTitle = prData.title || ""; - prLabels = prData.labels || []; - } else { - throw new Error("No PR data found"); + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; + } + } } - } catch (error) { - core.info(`Warning: Could not fetch PR ${pullNumber} details: ${error instanceof Error ? error.message : String(error)}`); - core.setFailed(`Failed to determine branch name for PR ${pullNumber}`); - return; } - core.info(`Target branch: ${branchName}`); - core.info(`PR title: ${prTitle}`); - core.info(`PR labels: ${prLabels.join(", ")}`); - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !prTitle.startsWith(titlePrefix)) { - core.setFailed(`Pull request title "${prTitle}" does not start with required prefix "${titlePrefix}"`); - return; - } - const requiredLabelsStr = process.env.GH_AW_PR_LABELS; - if (requiredLabelsStr) { - const requiredLabels = requiredLabelsStr.split(",").map(label => label.trim()); - const missingLabels = requiredLabels.filter(label => !prLabels.includes(label)); - if (missingLabels.length > 0) { - core.setFailed(`Pull request is missing required labels: ${missingLabels.join(", ")}. Current labels: ${prLabels.join(", ")}`); - return; + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (titlePrefix) { - core.info(`✓ Title prefix validation passed: "${titlePrefix}"`); - } - if (requiredLabelsStr) { - core.info(`✓ Labels validation passed: ${requiredLabelsStr}`); - } - const hasChanges = !isEmpty; - core.info(`Switching to branch: ${branchName}`); - try { - await exec.exec("git fetch origin"); - } catch (fetchError) { - core.setFailed(`Failed to fetch from origin: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`); - return; - } - try { - await exec.exec(`git rev-parse --verify origin/${branchName}`); - } catch (verifyError) { - core.setFailed(`Branch ${branchName} does not exist on origin, can't push to it: ${verifyError instanceof Error ? verifyError.message : String(verifyError)}`); - return; - } - try { - await exec.exec(`git checkout -B ${branchName} origin/${branchName}`); - core.info(`Checked out existing branch from origin: ${branchName}`); - } catch (checkoutError) { - core.setFailed(`Failed to checkout branch ${branchName}: ${checkoutError instanceof Error ? checkoutError.message : String(checkoutError)}`); - return; - } - if (!isEmpty) { - core.info("Applying patch..."); - try { - const commitTitleSuffix = process.env.GH_AW_COMMIT_TITLE_SUFFIX; - if (commitTitleSuffix) { - core.info(`Appending commit title suffix: "${commitTitleSuffix}"`); - let patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchContent = patchContent.replace(/^Subject: (?:\[PATCH\] )?(.*)$/gm, (match, title) => `Subject: [PATCH] ${title}${commitTitleSuffix}`); - fs.writeFileSync("/tmp/gh-aw/aw.patch", patchContent, "utf8"); - core.info(`Patch modified with commit title suffix: "${commitTitleSuffix}"`); - } - const finalPatchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - const patchLines = finalPatchContent.split("\n"); - const previewLineCount = Math.min(100, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - await exec.exec(`git push origin ${branchName}`); - core.info(`Changes committed and pushed to branch: ${branchName}`); - } catch (error) { - core.error(`Failed to apply patch: ${error instanceof Error ? error.message : String(error)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const logResult = await exec.getExecOutput("git", ["log", "--oneline", "-5"]); - core.info("Recent commits (last 5):"); - core.info(logResult.stdout); - const diffResult = await exec.getExecOutput("git", ["diff", "HEAD"]); - core.info("Uncommitted changes:"); - core.info(diffResult.stdout && diffResult.stdout.trim() ? diffResult.stdout : "(no uncommitted changes)"); - const patchDiffResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch diff:"); - core.info(patchDiffResult.stdout); - const patchFullResult = await exec.getExecOutput("git", ["am", "--show-current-patch"]); - core.info("Failed patch (full):"); - core.info(patchFullResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - } else { - core.info("Skipping patch application (empty patch)"); - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - core.setFailed("No changes to apply - failing as configured by if-no-changes: error"); - return; - case "ignore": - break; - case "warn": - default: - core.info(message); - break; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } + await core.summary.addRaw(summaryContent).write(); } - const commitShaRes = await exec.getExecOutput("git", ["rev-parse", "HEAD"]); - if (commitShaRes.exitCode !== 0) throw new Error("Failed to get commit SHA"); - const commitSha = commitShaRes.stdout.trim(); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const repoUrl = context.payload.repository ? context.payload.repository.html_url : `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - const pushUrl = `${repoUrl}/tree/${branchName}`; - const commitUrl = `${repoUrl}/commit/${commitSha}`; - core.setOutput("branch_name", branchName); - core.setOutput("commit_sha", commitSha); - core.setOutput("push_url", pushUrl); - core.setOutput("commit_url", commitUrl); - if (hasChanges) { - await updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl); - } - const summaryTitle = hasChanges ? "Push to Branch" : "Push to Branch (No Changes)"; - const summaryContent = hasChanges - ? ` - ## ${summaryTitle} - - **Branch**: \`${branchName}\` - - **Commit**: [${commitSha.substring(0, 7)}](${commitUrl}) - - **URL**: [${pushUrl}](${pushUrl}) - ` - : ` - ## ${summaryTitle} - - **Branch**: \`${branchName}\` - - **Status**: No changes to apply (noop operation) - - **URL**: [${pushUrl}](${pushUrl}) - `; - await core.summary.addRaw(summaryContent).write(); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); diff --git a/.github/workflows/notion-issue-summary.lock.yml b/.github/workflows/notion-issue-summary.lock.yml index 576b3c5567..05e3c88f19 100644 --- a/.github/workflows/notion-issue-summary.lock.yml +++ b/.github/workflows/notion-issue-summary.lock.yml @@ -5690,6 +5690,7 @@ jobs: - agent - detection - notion_add_comment + - safe_outputs if: (always()) && (needs.agent.result != 'skipped') runs-on: ubuntu-slim permissions: @@ -6578,3 +6579,205 @@ jobs: } } + safe_outputs: + needs: + - agent + - detection + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.detection.outputs.success == 'true') + runs-on: ubuntu-slim + timeout-minutes: 15 + outputs: + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} + steps: + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); + async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); + return; + } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; + } + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); + return; + } + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; + } + } + } + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } + } + await core.summary.addRaw(summaryContent).write(); + } + core.info(`Completed processing ${result.items.length} safe output item(s)`); + } + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); + diff --git a/.github/workflows/org-health-report.lock.yml b/.github/workflows/org-health-report.lock.yml index 600c234ab8..3e8a06cd90 100644 --- a/.github/workflows/org-health-report.lock.yml +++ b/.github/workflows/org-health-report.lock.yml @@ -7412,8 +7412,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7426,1311 +7434,178 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Organization Health Report" - GH_AW_ENGINE_ID: "copilot" - with: + GH_AW_DISCUSSION_CATEGORY: "reports" +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; } } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); - - name: Upload Assets - id: upload_assets - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'upload_asset')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Organization Health Report" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const path = require("path"); - const crypto = require("crypto"); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function normalizeBranchName(branchName) { - if (!branchName || typeof branchName !== "string" || branchName.trim() === "") { - return branchName; - } - let normalized = branchName.replace(/[^a-zA-Z0-9\-_/.]+/g, "-"); - normalized = normalized.replace(/-+/g, "-"); - normalized = normalized.replace(/^-+|-+$/g, ""); - if (normalized.length > 128) { - normalized = normalized.substring(0, 128); - } - normalized = normalized.replace(/-+$/, ""); - normalized = normalized.toLowerCase(); - return normalized; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const branchName = process.env.GH_AW_ASSETS_BRANCH; - if (!branchName || typeof branchName !== "string") { - core.setFailed("GH_AW_ASSETS_BRANCH environment variable is required but not set"); - return; - } - const normalizedBranchName = normalizeBranchName(branchName); - core.info(`Using assets branch: ${normalizedBranchName}`); - const result = loadAgentOutput(); - if (!result.success) { - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - const uploadItems = result.items.filter( item => item.type === "upload_assets"); - const uploadAssetItems = result.items.filter( item => item.type === "upload_asset"); - const allUploadItems = [...uploadItems, ...uploadAssetItems]; - if (allUploadItems.length === 0) { - core.info("No upload-asset items found in agent output"); - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - core.info(`Found ${allUploadItems.length} upload-asset item(s)`); - let uploadCount = 0; - let hasChanges = false; + (async () => { try { - try { - await exec.exec(`git rev-parse --verify origin/${normalizedBranchName}`); - await exec.exec(`git checkout -B ${normalizedBranchName} origin/${normalizedBranchName}`); - core.info(`Checked out existing branch from origin: ${normalizedBranchName}`); - } catch (originError) { - if (!normalizedBranchName.startsWith("assets/")) { - core.setFailed( - `Branch '${normalizedBranchName}' does not start with the required 'assets/' prefix. ` + - `Orphaned branches can only be automatically created under the 'assets/' prefix. ` + - `Please create the branch manually first, or use a branch name starting with 'assets/'.` - ); - return; - } - core.info(`Creating new orphaned branch: ${normalizedBranchName}`); - await exec.exec(`git checkout --orphan ${normalizedBranchName}`); - await exec.exec(`git rm -rf .`); - await exec.exec(`git clean -fdx`); - } - for (const asset of uploadAssetItems) { - try { - const { fileName, sha, size, targetFileName } = asset; - if (!fileName || !sha || !targetFileName) { - core.error(`Invalid asset entry missing required fields: ${JSON.stringify(asset)}`); - continue; - } - const assetSourcePath = path.join("/tmp/gh-aw/safeoutputs/assets", fileName); - if (!fs.existsSync(assetSourcePath)) { - core.warning(`Asset file not found: ${assetSourcePath}`); - continue; - } - const fileContent = fs.readFileSync(assetSourcePath); - const computedSha = crypto.createHash("sha256").update(fileContent).digest("hex"); - if (computedSha !== sha) { - core.warning(`SHA mismatch for ${fileName}: expected ${sha}, got ${computedSha}`); - continue; - } - if (fs.existsSync(targetFileName)) { - core.info(`Asset ${targetFileName} already exists, skipping`); - continue; - } - fs.copyFileSync(assetSourcePath, targetFileName); - await exec.exec(`git add "${targetFileName}"`); - uploadCount++; - hasChanges = true; - core.info(`Added asset: ${targetFileName} (${size} bytes)`); - } catch (error) { - core.warning(`Failed to process asset ${asset.fileName}: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (hasChanges) { - const commitMessage = `[skip-ci] Add ${uploadCount} asset(s)`; - await exec.exec(`git`, [`commit`, `-m`, commitMessage]); - if (isStaged) { - core.summary.addRaw("## Staged Asset Publication"); - } else { - await exec.exec(`git push origin ${normalizedBranchName}`); - core.summary.addRaw("## Assets").addRaw(`Successfully uploaded **${uploadCount}** assets to branch \`${normalizedBranchName}\``).addRaw(""); - core.info(`Successfully uploaded ${uploadCount} assets to branch ${normalizedBranchName}`); - } - for (const asset of uploadAssetItems) { - if (asset.fileName && asset.sha && asset.size && asset.url) { - core.summary.addRaw(`- [\`${asset.fileName}\`](${asset.url}) → \`${asset.targetFileName}\` (${asset.size} bytes)`); - } - } - core.summary.write(); - } else { - core.info("No new assets to upload"); - } + await main(); } catch (error) { - core.setFailed(`Failed to upload assets: ${error instanceof Error ? error.message : String(error)}`); - return; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - core.setOutput("upload_count", uploadCount.toString()); - core.setOutput("branch_name", normalizedBranchName); - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/org-wide-rollout.lock.yml b/.github/workflows/org-wide-rollout.lock.yml index 8989f48813..0f4ea3d4a4 100644 --- a/.github/workflows/org-wide-rollout.lock.yml +++ b/.github/workflows/org-wide-rollout.lock.yml @@ -7387,14 +7387,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} - add_labels_labels_added: ${{ steps.add_labels.outputs.labels_added }} - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} - create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7407,3034 +7409,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash - run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/safe_output_helpers.cjs << 'EOF_80a143d8' - // @ts-check - /// - - /** - * Shared helper functions for safe-output scripts - * Provides common validation and target resolution logic - */ - - /** - * Parse a comma-separated list of allowed items from environment variable - * @param {string|undefined} envValue - Environment variable value - * @returns {string[]|undefined} Array of allowed items, or undefined if no restrictions - */ - function parseAllowedItems(envValue) { - const trimmed = envValue?.trim(); - if (!trimmed) { - return undefined; - } - return trimmed - .split(",") - .map(item => item.trim()) - .filter(item => item); - } - - /** - * Parse and validate max count from environment variable - * @param {string|undefined} envValue - Environment variable value - * @param {number} defaultValue - Default value if not specified - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function parseMaxCount(envValue, defaultValue = 3) { - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - /** - * Resolve the target number (issue/PR) based on configuration and context - * @param {Object} params - Resolution parameters - * @param {string} params.targetConfig - Target configuration ("triggering", "*", or explicit number) - * @param {any} params.item - Safe output item with optional item_number or pull_request_number - * @param {any} params.context - GitHub Actions context - * @param {string} params.itemType - Type of item being processed (for error messages) - * @param {boolean} params.supportsPR - Whether this safe output supports PR context - * @returns {{success: true, number: number, contextType: string} | {success: false, error: string, shouldFail: boolean}} Resolution result - */ - function resolveTarget(params) { - const { targetConfig, item, context, itemType, supportsPR = false } = params; - - // Check context type - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - - // Default target is "triggering" - const target = targetConfig || "triggering"; - - // Validate context for triggering mode - if (target === "triggering") { - if (supportsPR) { - if (!isIssueContext && !isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in issue or pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } else { - if (!isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } - } - - // Resolve target number - let itemNumber; - let contextType; - - if (target === "*") { - // Use item_number, issue_number, or pull_request_number from item - const numberField = supportsPR ? item.item_number || item.issue_number || item.pull_request_number : item.pull_request_number; - - if (numberField) { - itemNumber = typeof numberField === "number" ? numberField : parseInt(String(numberField), 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "item_number/issue_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, - shouldFail: true, - }; - } - contextType = supportsPR && (item.item_number || item.issue_number) ? "issue" : "pull request"; - } else { - return { - success: false, - error: `Target is "*" but no ${supportsPR ? "item_number/issue_number" : "pull_request_number"} specified in ${itemType} item`, - shouldFail: true, - }; - } - } else if (target !== "triggering") { - // Explicit number - itemNumber = parseInt(target, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "issue" : "pull request"} number in target configuration: ${target}`, - shouldFail: true, - }; - } - contextType = supportsPR ? "issue" : "pull request"; - } else { - // Use triggering context - if (isIssueContext) { - if (context.payload.issue) { - itemNumber = context.payload.issue.number; - contextType = "issue"; - } else { - return { - success: false, - error: "Issue context detected but no issue found in payload", - shouldFail: true, - }; - } - } else if (isPRContext) { - if (context.payload.pull_request) { - itemNumber = context.payload.pull_request.number; - contextType = "pull request"; - } else { - return { - success: false, - error: "Pull request context detected but no pull request found in payload", - shouldFail: true, - }; - } - } - } - - if (!itemNumber) { - return { - success: false, - error: `Could not determine ${supportsPR ? "issue or pull request" : "pull request"} number`, - shouldFail: true, - }; - } - - return { - success: true, - number: itemNumber, - contextType: contextType || (supportsPR ? "issue" : "pull request"), - }; - } - - module.exports = { - parseAllowedItems, - parseMaxCount, - resolveTarget, - }; - - EOF_80a143d8 - cat > /tmp/gh-aw/scripts/safe_output_processor.cjs << 'EOF_8f3864e2' - // @ts-check - /// - - /** - * Shared processor for safe-output scripts - * Provides common pipeline: load agent output, handle staged mode, parse config, resolve target - */ - - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { parseAllowedItems, resolveTarget } = require('/tmp/gh-aw/scripts/safe_output_helpers.cjs'); - const { getSafeOutputConfig, validateMaxCount } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs'); - - /** - * @typedef {Object} ProcessorConfig - * @property {string} itemType - The type field value to match in agent output (e.g., "add_labels") - * @property {string} configKey - The key to use when reading from config.json (e.g., "add_labels") - * @property {string} displayName - Human-readable name for logging (e.g., "Add Labels") - * @property {string} itemTypeName - Name used in error messages (e.g., "label addition") - * @property {boolean} [supportsPR] - When true, allows both issue AND PR contexts; when false, only PR context (default: false) - * @property {boolean} [supportsIssue] - When true, passes supportsPR=true to resolveTarget to enable both contexts (default: false) - * @property {boolean} [findMultiple] - Whether to find multiple items instead of just one (default: false) - * @property {Object} envVars - Environment variable names - * @property {string} [envVars.allowed] - Env var for allowed items list - * @property {string} [envVars.maxCount] - Env var for max count - * @property {string} [envVars.target] - Env var for target configuration - */ - - /** - * @typedef {Object} ProcessorResult - * @property {boolean} success - Whether processing should continue - * @property {any} [item] - The found item (when findMultiple is false) - * @property {any[]} [items] - The found items (when findMultiple is true) - * @property {Object} [config] - Parsed configuration - * @property {string[]|undefined} [config.allowed] - Allowed items list - * @property {number} [config.maxCount] - Maximum count - * @property {string} [config.target] - Target configuration - * @property {Object} [targetResult] - Result from resolveTarget (when findMultiple is false) - * @property {number} [targetResult.number] - Target issue/PR number - * @property {string} [targetResult.contextType] - Type of context (issue or pull request) - * @property {string} [reason] - Reason why processing should not continue - */ - - /** - * Process the initial steps common to safe-output scripts: - * 1. Load agent output - * 2. Find matching item(s) - * 3. Handle staged mode - * 4. Parse configuration - * 5. Resolve target (for single-item processors) - * - * @param {ProcessorConfig} config - Processor configuration - * @param {Object} stagedPreviewOptions - Options for staged preview - * @param {string} stagedPreviewOptions.title - Title for staged preview - * @param {string} stagedPreviewOptions.description - Description for staged preview - * @param {(item: any, index: number) => string} stagedPreviewOptions.renderItem - Function to render item in preview - * @returns {Promise} Processing result - */ - async function processSafeOutput(config, stagedPreviewOptions) { - const { itemType, configKey, displayName, itemTypeName, supportsPR = false, supportsIssue = false, findMultiple = false, envVars } = config; - - // Step 1: Load agent output - const result = loadAgentOutput(); - if (!result.success) { - return { success: false, reason: "Agent output not available" }; - } - - // Step 2: Find matching item(s) - let items; - if (findMultiple) { - items = result.items.filter(item => item.type === itemType); - if (items.length === 0) { - core.info(`No ${itemType} items found in agent output`); - return { success: false, reason: `No ${itemType} items found` }; - } - core.info(`Found ${items.length} ${itemType} item(s)`); - } else { - const item = result.items.find(item => item.type === itemType); - if (!item) { - core.warning(`No ${itemType.replace(/_/g, "-")} item found in agent output`); - return { success: false, reason: `No ${itemType} item found` }; - } - items = [item]; - // Log item details based on common fields - const itemDetails = getItemDetails(item); - if (itemDetails) { - core.info(`Found ${itemType.replace(/_/g, "-")} item with ${itemDetails}`); - } - } - - // Step 3: Handle staged mode - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - await generateStagedPreview({ - title: stagedPreviewOptions.title, - description: stagedPreviewOptions.description, - items: items, - renderItem: stagedPreviewOptions.renderItem, - }); - return { success: false, reason: "Staged mode - preview generated" }; - } - - // Step 4: Parse configuration - const safeOutputConfig = getSafeOutputConfig(configKey); - - // Parse allowed items (from env or config) - const allowedEnvValue = envVars.allowed ? process.env[envVars.allowed] : undefined; - const allowed = parseAllowedItems(allowedEnvValue) || safeOutputConfig.allowed; - if (allowed) { - core.info(`Allowed ${itemTypeName}s: ${JSON.stringify(allowed)}`); - } else { - core.info(`No ${itemTypeName} restrictions - any ${itemTypeName}s are allowed`); - } - - // Parse max count (env takes priority, then config) - const maxCountEnvValue = envVars.maxCount ? process.env[envVars.maxCount] : undefined; - const maxCountResult = validateMaxCount(maxCountEnvValue, safeOutputConfig.max); - if (!maxCountResult.valid) { - core.setFailed(maxCountResult.error); - return { success: false, reason: "Invalid max count configuration" }; - } - const maxCount = maxCountResult.value; - core.info(`Max count: ${maxCount}`); - - // Get target configuration - const target = envVars.target ? process.env[envVars.target] || "triggering" : "triggering"; - core.info(`${displayName} target configuration: ${target}`); - - // For multiple items, return early without target resolution - if (findMultiple) { - return { - success: true, - items: items, - config: { - allowed, - maxCount, - target, - }, - }; - } - - // Step 5: Resolve target (for single-item processors) - const item = items[0]; - const targetResult = resolveTarget({ - targetConfig: target, - item: item, - context, - itemType: itemTypeName, - // supportsPR in resolveTarget: true=both issue and PR contexts, false=PR-only - // If supportsIssue is true, we pass supportsPR=true to enable both contexts - supportsPR: supportsPR || supportsIssue, - }); - - if (!targetResult.success) { - if (targetResult.shouldFail) { - core.setFailed(targetResult.error); - } else { - core.info(targetResult.error); - } - return { success: false, reason: targetResult.error }; - } - - return { - success: true, - item: item, - config: { - allowed, - maxCount, - target, - }, - targetResult: { - number: targetResult.number, - contextType: targetResult.contextType, - }, - }; - } - - /** - * Get a description of item details for logging - * @param {any} item - The safe output item - * @returns {string|null} Description string or null - */ - function getItemDetails(item) { - if (item.labels && Array.isArray(item.labels)) { - return `${item.labels.length} labels`; - } - if (item.reviewers && Array.isArray(item.reviewers)) { - return `${item.reviewers.length} reviewers`; - } - return null; - } - - /** - * Sanitize and deduplicate an array of string items - * @param {any[]} items - Raw items array - * @returns {string[]} Sanitized and deduplicated array - */ - function sanitizeItems(items) { - return items - .filter(item => item != null && item !== false && item !== 0) - .map(item => String(item).trim()) - .filter(item => item) - .filter((item, index, arr) => arr.indexOf(item) === index); - } - - /** - * Filter items by allowed list - * @param {string[]} items - Items to filter - * @param {string[]|undefined} allowed - Allowed items list (undefined means all allowed) - * @returns {string[]} Filtered items - */ - function filterByAllowed(items, allowed) { - if (!allowed || allowed.length === 0) { - return items; - } - return items.filter(item => allowed.includes(item)); - } - - /** - * Limit items to max count - * @param {string[]} items - Items to limit - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Limited items - */ - function limitToMaxCount(items, maxCount) { - if (items.length > maxCount) { - core.info(`Too many items (${items.length}), limiting to ${maxCount}`); - return items.slice(0, maxCount); - } - return items; - } - - /** - * Process items through the standard pipeline: filter by allowed, sanitize, dedupe, limit - * @param {any[]} rawItems - Raw items array from agent output - * @param {string[]|undefined} allowed - Allowed items list - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Processed items - */ - function processItems(rawItems, allowed, maxCount) { - // Filter by allowed list first - const filtered = filterByAllowed(rawItems, allowed); - - // Sanitize and deduplicate - const sanitized = sanitizeItems(filtered); - - // Limit to max count - return limitToMaxCount(sanitized, maxCount); - } - - module.exports = { - processSafeOutput, - sanitizeItems, - filterByAllowed, - limitToMaxCount, - processItems, - }; - - EOF_8f3864e2 - cat > /tmp/gh-aw/scripts/safe_output_validator.cjs << 'EOF_437e6b4f' - // @ts-check - /// - - const fs = require("fs"); - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - - /** - * Load and parse the safe outputs configuration from config.json - * @returns {object} The parsed configuration object - */ - function loadSafeOutputsConfig() { - const configPath = "/tmp/gh-aw/safeoutputs/config.json"; - try { - if (!fs.existsSync(configPath)) { - core.warning(`Config file not found at ${configPath}, using defaults`); - return {}; - } - const configContent = fs.readFileSync(configPath, "utf8"); - return JSON.parse(configContent); - } catch (error) { - core.warning(`Failed to load config: ${error instanceof Error ? error.message : String(error)}`); - return {}; - } - } - - /** - * Get configuration for a specific safe output type - * @param {string} outputType - The type of safe output (e.g., "add_labels", "update_issue") - * @returns {{max?: number, target?: string, allowed?: string[]}} The configuration for this output type - */ - function getSafeOutputConfig(outputType) { - const config = loadSafeOutputsConfig(); - return config[outputType] || {}; - } - - /** - * Validate and sanitize a title string - * @param {any} title - The title to validate - * @param {string} fieldName - The name of the field for error messages (default: "title") - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateTitle(title, fieldName = "title") { - if (title === undefined || title === null) { - return { valid: false, error: `${fieldName} is required` }; - } - - if (typeof title !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - const trimmed = title.trim(); - if (trimmed.length === 0) { - return { valid: false, error: `${fieldName} cannot be empty` }; - } - - return { valid: true, value: trimmed }; - } - - /** - * Validate and sanitize a body/content string - * @param {any} body - The body to validate - * @param {string} fieldName - The name of the field for error messages (default: "body") - * @param {boolean} required - Whether the body is required (default: false) - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateBody(body, fieldName = "body", required = false) { - if (body === undefined || body === null) { - if (required) { - return { valid: false, error: `${fieldName} is required` }; - } - return { valid: true, value: "" }; - } - - if (typeof body !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - return { valid: true, value: body }; - } - - /** - * Validate and sanitize an array of labels - * @param {any} labels - The labels to validate - * @param {string[]|undefined} allowedLabels - Optional list of allowed labels - * @param {number} maxCount - Maximum number of labels allowed - * @returns {{valid: boolean, value?: string[], error?: string}} Validation result - */ - function validateLabels(labels, allowedLabels = undefined, maxCount = 3) { - if (!labels || !Array.isArray(labels)) { - return { valid: false, error: "labels must be an array" }; - } - - // Check for removal attempts (labels starting with '-') - for (const label of labels) { - if (label && typeof label === "string" && label.startsWith("-")) { - return { valid: false, error: `Label removal is not permitted. Found line starting with '-': ${label}` }; - } - } - - // Filter labels based on allowed list if provided - let validLabels = labels; - if (allowedLabels && allowedLabels.length > 0) { - validLabels = labels.filter(label => allowedLabels.includes(label)); - } - - // Sanitize and deduplicate labels - const uniqueLabels = validLabels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - - // Apply max count limit - if (uniqueLabels.length > maxCount) { - core.info(`Too many labels (${uniqueLabels.length}), limiting to ${maxCount}`); - return { valid: true, value: uniqueLabels.slice(0, maxCount) }; - } - - if (uniqueLabels.length === 0) { - return { valid: false, error: "No valid labels found after sanitization" }; - } - - return { valid: true, value: uniqueLabels }; - } - - /** - * Validate max count from environment variable with config fallback - * @param {string|undefined} envValue - Environment variable value - * @param {number|undefined} configDefault - Default from config.json - * @param {number} [fallbackDefault] - Fallback default for testing (optional, defaults to 1) - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function validateMaxCount(envValue, configDefault, fallbackDefault = 1) { - // Priority: env var > config.json > fallback default - // In production, config.json should always have the default - // Fallback is provided for backward compatibility and testing - const defaultValue = configDefault !== undefined ? configDefault : fallbackDefault; - - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - module.exports = { - loadSafeOutputsConfig, - getSafeOutputConfig, - validateTitle, - validateBody, - validateLabels, - validateMaxCount, - }; - - EOF_437e6b4f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_ISSUE_LABELS: "campaign-tracker,org-rollout" - GH_AW_WORKFLOW_NAME: "Campaign - Org-Wide Rollout" - GH_AW_ENGINE_ID: "copilot" + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function main() { - core.setOutput("issue_number", ""); - core.setOutput("issue_url", ""); - core.setOutput("temporary_id_map", "{}"); - core.setOutput("issues_to_assign_copilot", ""); - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; - } - if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - content += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; - } - return content; - }, - }); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; - } - } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } - } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } - } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; - } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; - } - await core.summary.addRaw(summaryContent).write(); - } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); - } - (async () => { - await main(); - })(); - - name: Create Pull Request - id: create_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_ISSUE_LABELS: "campaign-tracker,org-rollout" GH_AW_PR_LABELS: "campaign-pr,org-rollout" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Campaign - Org-Wide Rollout" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { - core.setOutput("pull_request_number", ""); - core.setOutput("pull_request_url", ""); core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); - core.setOutput("branch_name", ""); - core.setOutput("fallback_used", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const workflowId = process.env.GH_AW_WORKFLOW_ID; - if (!workflowId) { - throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); - } - const baseBranch = process.env.GH_AW_BASE_BRANCH; - if (!baseBranch) { - throw new Error("GH_AW_BASE_BRANCH environment variable is required"); - } - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - let outputContent = ""; - if (agentOutputFile.trim() !== "") { - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - } - const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn"; - const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - let patchContent = ""; - let isEmpty = true; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } - if (patchContent.includes("Failed to generate patch")) { - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return; - } - throw new Error(message); - } - core.info("Patch size validation passed"); - } - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to push - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); return; } - core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; - } - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); - return; - } - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - if (!title) { - title = "Agent Output"; - } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - const labelsEnv = process.env.GH_AW_PR_LABELS; - const labels = labelsEnv - ? labelsEnv - .split(",") - .map( label => label.trim()) - .filter( label => label) - : []; - const draftEnv = process.env.GH_AW_PR_DRAFT; - const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); - const randomHex = crypto.randomBytes(8).toString("hex"); - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; - } else { - branchName = `${branchName}-${randomHex}`; - core.info(`Using branch name from JSONL with added salt: ${branchName}`); - } - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); - core.info(`Fetching latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${baseBranch}`); - core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`); - await exec.exec(`git checkout -b ${branchName}`); - core.info(`Created new branch from base: ${branchName}`); - if (!isEmpty) { - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - try { - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - try { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Changes pushed to branch"); - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - > [!NOTE] - > This was originally intended as a pull request, but the git push operation failed. - > - > **Workflow Run:** [View run details and download patch artifact](${runUrl}) - > - > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above. - To apply the patch locally: - \`\`\`sh - # Download the artifact from the workflow run ${runUrl} - # (Use GitHub MCP tools if gh CLI is not available) - gh run download ${runId} -n aw.patch - # Apply the patch - git am aw.patch - \`\`\` - ${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - core.setOutput("push_failed", "true"); - await core.summary - .addRaw( - ` - ## Push Failure Fallback - - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} - - **Fallback Issue:** [#${issue.number}](${issue.html_url}) - - **Patch Artifact:** Available in workflow run artifacts - - **Note:** Push failed, created issue as fallback - ` - ) - .write(); - return; - } catch (issueError) { - core.setFailed( - `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` - ); - return; - } - } - } else { - core.info("Skipping patch application (empty patch)"); - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - try { - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; } - await exec.exec(`git push origin ${branchName}`); - core.info("Empty branch pushed successfully"); - } catch (pushError) { - core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - return; - } - } else { - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to apply - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - try { - const { data: pullRequest } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }); - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - labels: labels, - }); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } - core.setOutput("pull_request_number", pullRequest.number); - core.setOutput("pull_request_url", pullRequest.html_url); - core.setOutput("branch_name", branchName); - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - await core.summary - .addRaw( - ` - ## Pull Request - - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - - **Branch**: \`${branchName}\` - - **Base Branch**: \`${baseBranch}\` - ` - ) - .write(); - } catch (prError) { - core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); - core.info("Falling back to creating an issue instead"); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). - **Original error:** ${prError instanceof Error ? prError.message : String(prError)} - You can manually create a pull request from the branch if needed.${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - await core.summary - .addRaw( - ` - ## Fallback Issue Created - - **Issue**: [#${issue.number}](${issue.html_url}) - - **Branch**: [\`${branchName}\`](${branchUrl}) - - **Base Branch**: \`${baseBranch}\` - - **Note**: Pull request creation failed, created issue as fallback - ` - ) - .write(); - } catch (issueError) { - core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`); - return; - } - } - } - (async () => { await main(); })(); - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_CREATED_ISSUE_URL: ${{ steps.create_issue.outputs.issue_url }} - GH_AW_CREATED_ISSUE_NUMBER: ${{ steps.create_issue.outputs.issue_number }} - GH_AW_TEMPORARY_ID_MAP: ${{ steps.create_issue.outputs.temporary_id_map }} - GH_AW_CREATED_PULL_REQUEST_URL: ${{ steps.create_pull_request.outputs.pull_request_url }} - GH_AW_CREATED_PULL_REQUEST_NUMBER: ${{ steps.create_pull_request.outputs.pull_request_number }} - GH_AW_WORKFLOW_NAME: "Campaign - Org-Wide Rollout" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } + summaryContent += "---\n\n"; } } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } - } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } - const result = loadAgentOutput(); - if (!result.success) { + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } } - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; - } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); - return; } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; - } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; - } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); - } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); - } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); - - name: Add Labels - id: add_labels - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_labels')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Campaign - Org-Wide Rollout" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { processSafeOutput } = require('/tmp/gh-aw/scripts/safe_output_processor.cjs'); - const { validateLabels } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs'); - async function main() { - const result = await processSafeOutput( - { - itemType: "add_labels", - configKey: "add_labels", - displayName: "Labels", - itemTypeName: "label addition", - supportsPR: true, - supportsIssue: true, - envVars: { - allowed: "GH_AW_LABELS_ALLOWED", - maxCount: "GH_AW_LABELS_MAX_COUNT", - target: "GH_AW_LABELS_TARGET", - }, - }, - { - title: "Add Labels", - description: "The following labels would be added if staged mode was disabled:", - renderItem: item => { - let content = ""; - if (item.item_number) { - content += `**Target Issue:** #${item.item_number}\n\n`; - } else { - content += `**Target:** Current issue/PR\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels to add:** ${item.labels.join(", ")}\n\n`; - } - return content; - }, - } - ); - if (!result.success) { - return; - } - const { item: labelsItem, config, targetResult } = result; - if (!config || !targetResult || targetResult.number === undefined) { - core.setFailed("Internal error: config, targetResult, or targetResult.number is undefined"); - return; - } - const { allowed: allowedLabels, maxCount } = config; - const itemNumber = targetResult.number; - const { contextType } = targetResult; - const requestedLabels = labelsItem.labels || []; - core.info(`Requested labels: ${JSON.stringify(requestedLabels)}`); - const labelsResult = validateLabels(requestedLabels, allowedLabels, maxCount); - if (!labelsResult.valid) { - if (labelsResult.error && labelsResult.error.includes("No valid labels")) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` - ## Label Addition - No labels were added (no valid labels found in agent output). - ` - ) - .write(); - return; - } - core.setFailed(labelsResult.error || "Invalid labels"); - return; - } - const uniqueLabels = labelsResult.value || []; - if (uniqueLabels.length === 0) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` - ## Label Addition - No labels were added (no valid labels found in agent output). - ` - ) - .write(); - return; - } - core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${itemNumber}: ${JSON.stringify(uniqueLabels)}`); + (async () => { try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - labels: uniqueLabels, - }); - core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${itemNumber}`); - core.setOutput("labels_added", uniqueLabels.join("\n")); - const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); - await core.summary - .addRaw( - ` - ## Label Addition - Successfully added ${uniqueLabels.length} label(s) to ${contextType} #${itemNumber}: - ${labelsListMarkdown} - ` - ) - .write(); + await main(); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to add labels: ${errorMessage}`); - core.setFailed(`Failed to add labels: ${errorMessage}`); + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); diff --git a/.github/workflows/pdf-summary.lock.yml b/.github/workflows/pdf-summary.lock.yml index a7472a4ce9..9a4806c4a7 100644 --- a/.github/workflows/pdf-summary.lock.yml +++ b/.github/workflows/pdf-summary.lock.yml @@ -7660,8 +7660,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7674,1021 +7682,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Resource Summarizer Agent" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 📄 *Summary compiled by [{workflow_name}]({run_url})*\",\"runStarted\":\"📖 Page by page! [{workflow_name}]({run_url}) is reading through this {event_type}...\",\"runSuccess\":\"📚 TL;DR ready! [{workflow_name}]({run_url}) has distilled the essence. Knowledge condensed! ✨\",\"runFailure\":\"📖 Reading interrupted! [{workflow_name}]({run_url}) {status}. The document remains unsummarized...\"}" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } - } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; - } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); - } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); update_cache_memory: needs: diff --git a/.github/workflows/plan.lock.yml b/.github/workflows/plan.lock.yml index bc1560b3d0..6844984d6b 100644 --- a/.github/workflows/plan.lock.yml +++ b/.github/workflows/plan.lock.yml @@ -7691,9 +7691,16 @@ jobs: issues: write timeout-minutes: 15 outputs: - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7706,1214 +7713,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_ISSUE_TITLE_PREFIX: "[plan] " GH_AW_ISSUE_LABELS: "plan,ai-generated" - GH_AW_WORKFLOW_NAME: "Plan Command" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; + summaryContent += `**Repository:** ${item.repo}\n\n`; } if (item.body) { - content += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; - } - return content; - }, - }); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; - } - } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } - } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; + summaryContent += "---\n\n"; } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; - } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; } await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); + return; } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - core.info(`Successfully created ${createdIssues.length} issue(s)`); - } - (async () => { - await main(); - })(); - - name: Close Discussion - id: close_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'close_discussion')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Plan Command" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - async function getDiscussionDetails(github, owner, repo, discussionNumber) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - title - category { - name - } - labels(first: 100) { - nodes { - name - } - } - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } } - return repository.discussion; - } - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - return result.addDiscussionComment.comment; - } - async function closeDiscussion(github, discussionId, reason) { - const mutation = reason - ? ` - mutation($dId: ID!, $reason: DiscussionCloseReason!) { - closeDiscussion(input: { discussionId: $dId, reason: $reason }) { - discussion { - id - url - } - } - }` - : ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId }) { - discussion { - id - url - } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - }`; - const variables = reason ? { dId: discussionId, reason } : { dId: discussionId }; - const result = await github.graphql(mutation, variables); - return result.closeDiscussion.discussion; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const closeDiscussionItems = result.items.filter( item => item.type === "close_discussion"); - if (closeDiscussionItems.length === 0) { - core.info("No close-discussion items found in agent output"); - return; - } - core.info(`Found ${closeDiscussionItems.length} close-discussion item(s)`); - const requiredLabels = process.env.GH_AW_CLOSE_DISCUSSION_REQUIRED_LABELS ? process.env.GH_AW_CLOSE_DISCUSSION_REQUIRED_LABELS.split(",").map(l => l.trim()) : []; - const requiredTitlePrefix = process.env.GH_AW_CLOSE_DISCUSSION_REQUIRED_TITLE_PREFIX || ""; - const requiredCategory = process.env.GH_AW_CLOSE_DISCUSSION_REQUIRED_CATEGORY || ""; - const target = process.env.GH_AW_CLOSE_DISCUSSION_TARGET || "triggering"; - core.info(`Configuration: requiredLabels=${requiredLabels.join(",")}, requiredTitlePrefix=${requiredTitlePrefix}, requiredCategory=${requiredCategory}, target=${target}`); - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Close Discussions Preview\n\n"; - summaryContent += "The following discussions would be closed if staged mode was disabled:\n\n"; - for (let i = 0; i < closeDiscussionItems.length; i++) { - const item = closeDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - const discussionNumber = item.discussion_number; - if (discussionNumber) { - const repoUrl = getRepositoryUrl(); - const discussionUrl = `${repoUrl}/discussions/${discussionNumber}`; - summaryContent += `**Target Discussion:** [#${discussionNumber}](${discussionUrl})\n\n`; - } else { - summaryContent += `**Target:** Current discussion\n\n`; - } - if (item.reason) { - summaryContent += `**Reason:** ${item.reason}\n\n`; - } - summaryContent += `**Comment:**\n${item.body || "No content provided"}\n\n`; - if (requiredLabels.length > 0) { - summaryContent += `**Required Labels:** ${requiredLabels.join(", ")}\n\n`; } - if (requiredTitlePrefix) { - summaryContent += `**Required Title Prefix:** ${requiredTitlePrefix}\n\n`; - } - if (requiredCategory) { - summaryContent += `**Required Category:** ${requiredCategory}\n\n`; - } - summaryContent += "---\n\n"; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion close preview written to step summary"); - return; } - if (target === "triggering" && !isDiscussionContext) { - core.info('Target is "triggering" but not running in discussion context, skipping discussion close'); - return; + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } } - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const closedDiscussions = []; - for (let i = 0; i < closeDiscussionItems.length; i++) { - const item = closeDiscussionItems[i]; - core.info(`Processing close-discussion item ${i + 1}/${closeDiscussionItems.length}: bodyLength=${item.body.length}`); - let discussionNumber; - if (target === "*") { - const targetNumber = item.discussion_number; - if (targetNumber) { - discussionNumber = parseInt(targetNumber, 10); - if (isNaN(discussionNumber) || discussionNumber <= 0) { - core.info(`Invalid discussion number specified: ${targetNumber}`); - continue; - } - } else { - core.info(`Target is "*" but no discussion_number specified in close-discussion item`); - continue; - } - } else if (target && target !== "triggering") { - discussionNumber = parseInt(target, 10); - if (isNaN(discussionNumber) || discussionNumber <= 0) { - core.info(`Invalid discussion number in target configuration: ${target}`); - continue; - } - } else { - if (isDiscussionContext) { - discussionNumber = context.payload.discussion?.number; - if (!discussionNumber) { - core.info("Discussion context detected but no discussion found in payload"); - continue; - } - } else { - core.info("Not in discussion context and no explicit target specified"); - continue; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } } - try { - const discussion = await getDiscussionDetails(github, context.repo.owner, context.repo.repo, discussionNumber); - if (requiredLabels.length > 0) { - const discussionLabels = discussion.labels.nodes.map(l => l.name); - const hasRequiredLabel = requiredLabels.some(required => discussionLabels.includes(required)); - if (!hasRequiredLabel) { - core.info(`Discussion #${discussionNumber} does not have required labels: ${requiredLabels.join(", ")}`); - continue; - } - } - if (requiredTitlePrefix && !discussion.title.startsWith(requiredTitlePrefix)) { - core.info(`Discussion #${discussionNumber} does not have required title prefix: ${requiredTitlePrefix}`); - continue; - } - if (requiredCategory && discussion.category.name !== requiredCategory) { - core.info(`Discussion #${discussionNumber} is not in required category: ${requiredCategory}`); - continue; - } - let body = item.body.trim(); - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - body += getTrackerID("markdown"); - body += generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, undefined, undefined, triggeringDiscussionNumber); - core.info(`Adding comment to discussion #${discussionNumber}`); - core.info(`Comment content length: ${body.length}`); - const comment = await addDiscussionComment(github, discussion.id, body); - core.info("Added discussion comment: " + comment.url); - core.info(`Closing discussion #${discussionNumber} with reason: ${item.reason || "none"}`); - const closedDiscussion = await closeDiscussion(github, discussion.id, item.reason); - core.info("Closed discussion: " + closedDiscussion.url); - closedDiscussions.push({ - number: discussionNumber, - url: discussion.url, - comment_url: comment.url, - }); - if (i === closeDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussionNumber); - core.setOutput("discussion_url", discussion.url); - core.setOutput("comment_url", comment.url); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } - } catch (error) { - core.error(`✗ Failed to close discussion #${discussionNumber}: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } - } - if (closedDiscussions.length > 0) { - let summaryContent = "\n\n## Closed Discussions\n"; - for (const discussion of closedDiscussions) { - summaryContent += `- Discussion #${discussion.number}: [View Discussion](${discussion.url})\n`; - summaryContent += ` - Comment: [View Comment](${discussion.comment_url})\n`; } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully closed ${closedDiscussions.length} discussion(s)`); - return closedDiscussions; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); diff --git a/.github/workflows/poem-bot.lock.yml b/.github/workflows/poem-bot.lock.yml index 7c155b60f7..f6fc906637 100644 --- a/.github/workflows/poem-bot.lock.yml +++ b/.github/workflows/poem-bot.lock.yml @@ -8106,19 +8106,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} - add_labels_labels_added: ${{ steps.add_labels.outputs.labels_added }} - create_agent_task_task_number: ${{ steps.create_agent_task.outputs.task_number }} - create_agent_task_task_url: ${{ steps.create_agent_task.outputs.task_url }} - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} - create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} - push_to_pull_request_branch_commit_url: ${{ steps.push_to_pull_request_branch.outputs.commit_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -8131,5740 +8128,183 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash - run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_entity_helpers.cjs << 'EOF_96ffce00' - // @ts-check - /// - - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - - /** - * @typedef {'issue' | 'pull_request'} EntityType - */ - - /** - * @typedef {Object} EntityConfig - * @property {EntityType} entityType - The type of entity (issue or pull_request) - * @property {string} itemType - The agent output item type (e.g., "close_issue") - * @property {string} itemTypeDisplay - Human-readable item type for log messages (e.g., "close-issue") - * @property {string} numberField - The field name for the entity number in agent output (e.g., "issue_number") - * @property {string} envVarPrefix - Environment variable prefix (e.g., "GH_AW_CLOSE_ISSUE") - * @property {string[]} contextEvents - GitHub event names for this entity context - * @property {string} contextPayloadField - The field name in context.payload (e.g., "issue") - * @property {string} urlPath - URL path segment (e.g., "issues" or "pull") - * @property {string} displayName - Human-readable display name (e.g., "issue" or "pull request") - * @property {string} displayNamePlural - Human-readable display name plural (e.g., "issues" or "pull requests") - * @property {string} displayNameCapitalized - Capitalized display name (e.g., "Issue" or "Pull Request") - * @property {string} displayNameCapitalizedPlural - Capitalized display name plural (e.g., "Issues" or "Pull Requests") - */ - - /** - * @typedef {Object} EntityCallbacks - * @property {(github: any, owner: string, repo: string, entityNumber: number) => Promise<{number: number, title: string, labels: Array<{name: string}>, html_url: string, state: string}>} getDetails - * @property {(github: any, owner: string, repo: string, entityNumber: number, message: string) => Promise<{id: number, html_url: string}>} addComment - * @property {(github: any, owner: string, repo: string, entityNumber: number) => Promise<{number: number, html_url: string, title: string}>} closeEntity - */ - - /** - * Build the run URL for the current workflow - * @returns {string} The workflow run URL - */ - function buildRunUrl() { - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - } - - /** - * Build comment body with tracker ID and footer - * @param {string} body - The original comment body - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - PR number that triggered this workflow - * @returns {string} The complete comment body with tracker ID and footer - */ - function buildCommentBody(body, triggeringIssueNumber, triggeringPRNumber) { - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runUrl = buildRunUrl(); - - let commentBody = body.trim(); - commentBody += getTrackerID("markdown"); - commentBody += generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, undefined); - - return commentBody; - } - - /** - * Check if labels match the required labels filter - * @param {Array<{name: string}>} entityLabels - Labels on the entity - * @param {string[]} requiredLabels - Required labels (any match) - * @returns {boolean} True if entity has at least one required label - */ - function checkLabelFilter(entityLabels, requiredLabels) { - if (requiredLabels.length === 0) { - return true; - } - const labelNames = entityLabels.map(l => l.name); - return requiredLabels.some(required => labelNames.includes(required)); - } - - /** - * Check if title matches the required prefix filter - * @param {string} title - Entity title - * @param {string} requiredTitlePrefix - Required title prefix - * @returns {boolean} True if title starts with required prefix - */ - function checkTitlePrefixFilter(title, requiredTitlePrefix) { - if (!requiredTitlePrefix) { - return true; - } - return title.startsWith(requiredTitlePrefix); - } - - /** - * Generate staged preview content for a close entity operation - * @param {EntityConfig} config - Entity configuration - * @param {any[]} items - Items to preview - * @param {string[]} requiredLabels - Required labels filter - * @param {string} requiredTitlePrefix - Required title prefix filter - * @returns {Promise} - */ - async function generateCloseEntityStagedPreview(config, items, requiredLabels, requiredTitlePrefix) { - let summaryContent = `## 🎭 Staged Mode: Close ${config.displayNameCapitalizedPlural} Preview\n\n`; - summaryContent += `The following ${config.displayNamePlural} would be closed if staged mode was disabled:\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += `### ${config.displayNameCapitalized} ${i + 1}\n`; - - const entityNumber = item[config.numberField]; - if (entityNumber) { - const repoUrl = getRepositoryUrl(); - const entityUrl = `${repoUrl}/${config.urlPath}/${entityNumber}`; - summaryContent += `**Target ${config.displayNameCapitalized}:** [#${entityNumber}](${entityUrl})\n\n`; - } else { - summaryContent += `**Target:** Current ${config.displayName}\n\n`; - } - - summaryContent += `**Comment:**\n${item.body || "No content provided"}\n\n`; - - if (requiredLabels.length > 0) { - summaryContent += `**Required Labels:** ${requiredLabels.join(", ")}\n\n`; - } - if (requiredTitlePrefix) { - summaryContent += `**Required Title Prefix:** ${requiredTitlePrefix}\n\n`; - } - - summaryContent += "---\n\n"; - } - - // Write to step summary - await core.summary.addRaw(summaryContent).write(); - core.info(`📝 ${config.displayNameCapitalized} close preview written to step summary`); - } - - /** - * Parse configuration from environment variables - * @param {string} envVarPrefix - Environment variable prefix - * @returns {{requiredLabels: string[], requiredTitlePrefix: string, target: string}} - */ - function parseEntityConfig(envVarPrefix) { - const labelsEnvVar = `${envVarPrefix}_REQUIRED_LABELS`; - const titlePrefixEnvVar = `${envVarPrefix}_REQUIRED_TITLE_PREFIX`; - const targetEnvVar = `${envVarPrefix}_TARGET`; - - const requiredLabels = process.env[labelsEnvVar] ? process.env[labelsEnvVar].split(",").map(l => l.trim()) : []; - const requiredTitlePrefix = process.env[titlePrefixEnvVar] || ""; - const target = process.env[targetEnvVar] || "triggering"; - - return { requiredLabels, requiredTitlePrefix, target }; - } - - /** - * Resolve the entity number based on target configuration and context - * @param {EntityConfig} config - Entity configuration - * @param {string} target - Target configuration ("triggering", "*", or explicit number) - * @param {any} item - The agent output item - * @param {boolean} isEntityContext - Whether we're in the correct entity context - * @returns {{success: true, number: number} | {success: false, message: string}} - */ - function resolveEntityNumber(config, target, item, isEntityContext) { - if (target === "*") { - const targetNumber = item[config.numberField]; - if (targetNumber) { - const parsed = parseInt(targetNumber, 10); - if (isNaN(parsed) || parsed <= 0) { - return { - success: false, - message: `Invalid ${config.displayName} number specified: ${targetNumber}`, - }; - } - return { success: true, number: parsed }; - } - return { - success: false, - message: `Target is "*" but no ${config.numberField} specified in ${config.itemTypeDisplay} item`, - }; - } - - if (target !== "triggering") { - const parsed = parseInt(target, 10); - if (isNaN(parsed) || parsed <= 0) { - return { - success: false, - message: `Invalid ${config.displayName} number in target configuration: ${target}`, - }; - } - return { success: true, number: parsed }; - } - - // Default behavior: use triggering entity - if (isEntityContext) { - const number = context.payload[config.contextPayloadField]?.number; - if (!number) { - return { - success: false, - message: `${config.displayNameCapitalized} context detected but no ${config.displayName} found in payload`, - }; - } - return { success: true, number }; - } - - return { - success: false, - message: `Not in ${config.displayName} context and no explicit target specified`, - }; - } - - /** - * Escape special markdown characters in a title - * @param {string} title - The title to escape - * @returns {string} Escaped title - */ - function escapeMarkdownTitle(title) { - return title.replace(/[[\]()]/g, "\\$&"); - } - - /** - * Process close entity items from agent output - * @param {EntityConfig} config - Entity configuration - * @param {EntityCallbacks} callbacks - Entity-specific API callbacks - * @returns {Promise|undefined>} - */ - async function processCloseEntityItems(config, callbacks) { - // Check if we're in staged mode - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - - const result = loadAgentOutput(); - if (!result.success) { - return; - } - - // Find all items of this type - const items = result.items.filter(/** @param {any} item */ item => item.type === config.itemType); - if (items.length === 0) { - core.info(`No ${config.itemTypeDisplay} items found in agent output`); - return; - } - - core.info(`Found ${items.length} ${config.itemTypeDisplay} item(s)`); - - // Get configuration from environment - const { requiredLabels, requiredTitlePrefix, target } = parseEntityConfig(config.envVarPrefix); - - core.info(`Configuration: requiredLabels=${requiredLabels.join(",")}, requiredTitlePrefix=${requiredTitlePrefix}, target=${target}`); - - // Check if we're in the correct entity context - const isEntityContext = config.contextEvents.some(event => context.eventName === event); - - // If in staged mode, emit step summary instead of closing entities - if (isStaged) { - await generateCloseEntityStagedPreview(config, items, requiredLabels, requiredTitlePrefix); - return; - } - - // Validate context based on target configuration - if (target === "triggering" && !isEntityContext) { - core.info(`Target is "triggering" but not running in ${config.displayName} context, skipping ${config.displayName} close`); - return; - } - - // Extract triggering context for footer generation - const triggeringIssueNumber = context.payload?.issue?.number; - const triggeringPRNumber = context.payload?.pull_request?.number; - - const closedEntities = []; - - // Process each item - for (let i = 0; i < items.length; i++) { - const item = items[i]; - core.info(`Processing ${config.itemTypeDisplay} item ${i + 1}/${items.length}: bodyLength=${item.body.length}`); - - // Resolve entity number - const resolved = resolveEntityNumber(config, target, item, isEntityContext); - if (!resolved.success) { - core.info(resolved.message); - continue; - } - const entityNumber = resolved.number; - - try { - // Fetch entity details to check filters - const entity = await callbacks.getDetails(github, context.repo.owner, context.repo.repo, entityNumber); - - // Apply label filter - if (!checkLabelFilter(entity.labels, requiredLabels)) { - core.info(`${config.displayNameCapitalized} #${entityNumber} does not have required labels: ${requiredLabels.join(", ")}`); - continue; - } - - // Apply title prefix filter - if (!checkTitlePrefixFilter(entity.title, requiredTitlePrefix)) { - core.info(`${config.displayNameCapitalized} #${entityNumber} does not have required title prefix: ${requiredTitlePrefix}`); - continue; - } - - // Check if already closed - if (entity.state === "closed") { - core.info(`${config.displayNameCapitalized} #${entityNumber} is already closed, skipping`); - continue; - } - - // Build comment body - const commentBody = buildCommentBody(item.body, triggeringIssueNumber, triggeringPRNumber); - - // Add comment before closing - const comment = await callbacks.addComment(github, context.repo.owner, context.repo.repo, entityNumber, commentBody); - core.info(`✓ Added comment to ${config.displayName} #${entityNumber}: ${comment.html_url}`); - - // Close the entity - const closedEntity = await callbacks.closeEntity(github, context.repo.owner, context.repo.repo, entityNumber); - core.info(`✓ Closed ${config.displayName} #${entityNumber}: ${closedEntity.html_url}`); - - closedEntities.push({ - entity: closedEntity, - comment, - }); - - // Set outputs for the last closed entity (for backward compatibility) - if (i === items.length - 1) { - const numberOutputName = config.entityType === "issue" ? "issue_number" : "pull_request_number"; - const urlOutputName = config.entityType === "issue" ? "issue_url" : "pull_request_url"; - core.setOutput(numberOutputName, closedEntity.number); - core.setOutput(urlOutputName, closedEntity.html_url); - core.setOutput("comment_url", comment.html_url); - } - } catch (error) { - core.error(`✗ Failed to close ${config.displayName} #${entityNumber}: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } - } - - // Write summary for all closed entities - if (closedEntities.length > 0) { - let summaryContent = `\n\n## Closed ${config.displayNameCapitalizedPlural}\n`; - for (const { entity, comment } of closedEntities) { - const escapedTitle = escapeMarkdownTitle(entity.title); - summaryContent += `- ${config.displayNameCapitalized} #${entity.number}: [${escapedTitle}](${entity.html_url}) ([comment](${comment.html_url}))\n`; - } - await core.summary.addRaw(summaryContent).write(); - } - - core.info(`Successfully closed ${closedEntities.length} ${config.displayName}(s)`); - return closedEntities; - } - - /** - * Configuration for closing issues - * @type {EntityConfig} - */ - const ISSUE_CONFIG = { - entityType: "issue", - itemType: "close_issue", - itemTypeDisplay: "close-issue", - numberField: "issue_number", - envVarPrefix: "GH_AW_CLOSE_ISSUE", - contextEvents: ["issues", "issue_comment"], - contextPayloadField: "issue", - urlPath: "issues", - displayName: "issue", - displayNamePlural: "issues", - displayNameCapitalized: "Issue", - displayNameCapitalizedPlural: "Issues", - }; - - /** - * Configuration for closing pull requests - * @type {EntityConfig} - */ - const PULL_REQUEST_CONFIG = { - entityType: "pull_request", - itemType: "close_pull_request", - itemTypeDisplay: "close-pull-request", - numberField: "pull_request_number", - envVarPrefix: "GH_AW_CLOSE_PR", - contextEvents: ["pull_request", "pull_request_review_comment"], - contextPayloadField: "pull_request", - urlPath: "pull", - displayName: "pull request", - displayNamePlural: "pull requests", - displayNameCapitalized: "Pull Request", - displayNameCapitalizedPlural: "Pull Requests", - }; - - module.exports = { - processCloseEntityItems, - generateCloseEntityStagedPreview, - checkLabelFilter, - checkTitlePrefixFilter, - parseEntityConfig, - resolveEntityNumber, - buildCommentBody, - escapeMarkdownTitle, - ISSUE_CONFIG, - PULL_REQUEST_CONFIG, - }; - - EOF_96ffce00 - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/safe_output_helpers.cjs << 'EOF_80a143d8' - // @ts-check - /// - - /** - * Shared helper functions for safe-output scripts - * Provides common validation and target resolution logic - */ - - /** - * Parse a comma-separated list of allowed items from environment variable - * @param {string|undefined} envValue - Environment variable value - * @returns {string[]|undefined} Array of allowed items, or undefined if no restrictions - */ - function parseAllowedItems(envValue) { - const trimmed = envValue?.trim(); - if (!trimmed) { - return undefined; - } - return trimmed - .split(",") - .map(item => item.trim()) - .filter(item => item); - } - - /** - * Parse and validate max count from environment variable - * @param {string|undefined} envValue - Environment variable value - * @param {number} defaultValue - Default value if not specified - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function parseMaxCount(envValue, defaultValue = 3) { - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - /** - * Resolve the target number (issue/PR) based on configuration and context - * @param {Object} params - Resolution parameters - * @param {string} params.targetConfig - Target configuration ("triggering", "*", or explicit number) - * @param {any} params.item - Safe output item with optional item_number or pull_request_number - * @param {any} params.context - GitHub Actions context - * @param {string} params.itemType - Type of item being processed (for error messages) - * @param {boolean} params.supportsPR - Whether this safe output supports PR context - * @returns {{success: true, number: number, contextType: string} | {success: false, error: string, shouldFail: boolean}} Resolution result - */ - function resolveTarget(params) { - const { targetConfig, item, context, itemType, supportsPR = false } = params; - - // Check context type - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - - // Default target is "triggering" - const target = targetConfig || "triggering"; - - // Validate context for triggering mode - if (target === "triggering") { - if (supportsPR) { - if (!isIssueContext && !isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in issue or pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } else { - if (!isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } - } - - // Resolve target number - let itemNumber; - let contextType; - - if (target === "*") { - // Use item_number, issue_number, or pull_request_number from item - const numberField = supportsPR ? item.item_number || item.issue_number || item.pull_request_number : item.pull_request_number; - - if (numberField) { - itemNumber = typeof numberField === "number" ? numberField : parseInt(String(numberField), 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "item_number/issue_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, - shouldFail: true, - }; - } - contextType = supportsPR && (item.item_number || item.issue_number) ? "issue" : "pull request"; - } else { - return { - success: false, - error: `Target is "*" but no ${supportsPR ? "item_number/issue_number" : "pull_request_number"} specified in ${itemType} item`, - shouldFail: true, - }; - } - } else if (target !== "triggering") { - // Explicit number - itemNumber = parseInt(target, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "issue" : "pull request"} number in target configuration: ${target}`, - shouldFail: true, - }; - } - contextType = supportsPR ? "issue" : "pull request"; - } else { - // Use triggering context - if (isIssueContext) { - if (context.payload.issue) { - itemNumber = context.payload.issue.number; - contextType = "issue"; - } else { - return { - success: false, - error: "Issue context detected but no issue found in payload", - shouldFail: true, - }; - } - } else if (isPRContext) { - if (context.payload.pull_request) { - itemNumber = context.payload.pull_request.number; - contextType = "pull request"; - } else { - return { - success: false, - error: "Pull request context detected but no pull request found in payload", - shouldFail: true, - }; - } - } - } - - if (!itemNumber) { - return { - success: false, - error: `Could not determine ${supportsPR ? "issue or pull request" : "pull request"} number`, - shouldFail: true, - }; - } - - return { - success: true, - number: itemNumber, - contextType: contextType || (supportsPR ? "issue" : "pull request"), - }; - } - - module.exports = { - parseAllowedItems, - parseMaxCount, - resolveTarget, - }; - - EOF_80a143d8 - cat > /tmp/gh-aw/scripts/safe_output_processor.cjs << 'EOF_8f3864e2' - // @ts-check - /// - - /** - * Shared processor for safe-output scripts - * Provides common pipeline: load agent output, handle staged mode, parse config, resolve target - */ - - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { parseAllowedItems, resolveTarget } = require('/tmp/gh-aw/scripts/safe_output_helpers.cjs'); - const { getSafeOutputConfig, validateMaxCount } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs'); - - /** - * @typedef {Object} ProcessorConfig - * @property {string} itemType - The type field value to match in agent output (e.g., "add_labels") - * @property {string} configKey - The key to use when reading from config.json (e.g., "add_labels") - * @property {string} displayName - Human-readable name for logging (e.g., "Add Labels") - * @property {string} itemTypeName - Name used in error messages (e.g., "label addition") - * @property {boolean} [supportsPR] - When true, allows both issue AND PR contexts; when false, only PR context (default: false) - * @property {boolean} [supportsIssue] - When true, passes supportsPR=true to resolveTarget to enable both contexts (default: false) - * @property {boolean} [findMultiple] - Whether to find multiple items instead of just one (default: false) - * @property {Object} envVars - Environment variable names - * @property {string} [envVars.allowed] - Env var for allowed items list - * @property {string} [envVars.maxCount] - Env var for max count - * @property {string} [envVars.target] - Env var for target configuration - */ - - /** - * @typedef {Object} ProcessorResult - * @property {boolean} success - Whether processing should continue - * @property {any} [item] - The found item (when findMultiple is false) - * @property {any[]} [items] - The found items (when findMultiple is true) - * @property {Object} [config] - Parsed configuration - * @property {string[]|undefined} [config.allowed] - Allowed items list - * @property {number} [config.maxCount] - Maximum count - * @property {string} [config.target] - Target configuration - * @property {Object} [targetResult] - Result from resolveTarget (when findMultiple is false) - * @property {number} [targetResult.number] - Target issue/PR number - * @property {string} [targetResult.contextType] - Type of context (issue or pull request) - * @property {string} [reason] - Reason why processing should not continue - */ - - /** - * Process the initial steps common to safe-output scripts: - * 1. Load agent output - * 2. Find matching item(s) - * 3. Handle staged mode - * 4. Parse configuration - * 5. Resolve target (for single-item processors) - * - * @param {ProcessorConfig} config - Processor configuration - * @param {Object} stagedPreviewOptions - Options for staged preview - * @param {string} stagedPreviewOptions.title - Title for staged preview - * @param {string} stagedPreviewOptions.description - Description for staged preview - * @param {(item: any, index: number) => string} stagedPreviewOptions.renderItem - Function to render item in preview - * @returns {Promise} Processing result - */ - async function processSafeOutput(config, stagedPreviewOptions) { - const { itemType, configKey, displayName, itemTypeName, supportsPR = false, supportsIssue = false, findMultiple = false, envVars } = config; - - // Step 1: Load agent output - const result = loadAgentOutput(); - if (!result.success) { - return { success: false, reason: "Agent output not available" }; - } - - // Step 2: Find matching item(s) - let items; - if (findMultiple) { - items = result.items.filter(item => item.type === itemType); - if (items.length === 0) { - core.info(`No ${itemType} items found in agent output`); - return { success: false, reason: `No ${itemType} items found` }; - } - core.info(`Found ${items.length} ${itemType} item(s)`); - } else { - const item = result.items.find(item => item.type === itemType); - if (!item) { - core.warning(`No ${itemType.replace(/_/g, "-")} item found in agent output`); - return { success: false, reason: `No ${itemType} item found` }; - } - items = [item]; - // Log item details based on common fields - const itemDetails = getItemDetails(item); - if (itemDetails) { - core.info(`Found ${itemType.replace(/_/g, "-")} item with ${itemDetails}`); - } - } - - // Step 3: Handle staged mode - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - await generateStagedPreview({ - title: stagedPreviewOptions.title, - description: stagedPreviewOptions.description, - items: items, - renderItem: stagedPreviewOptions.renderItem, - }); - return { success: false, reason: "Staged mode - preview generated" }; - } - - // Step 4: Parse configuration - const safeOutputConfig = getSafeOutputConfig(configKey); - - // Parse allowed items (from env or config) - const allowedEnvValue = envVars.allowed ? process.env[envVars.allowed] : undefined; - const allowed = parseAllowedItems(allowedEnvValue) || safeOutputConfig.allowed; - if (allowed) { - core.info(`Allowed ${itemTypeName}s: ${JSON.stringify(allowed)}`); - } else { - core.info(`No ${itemTypeName} restrictions - any ${itemTypeName}s are allowed`); - } - - // Parse max count (env takes priority, then config) - const maxCountEnvValue = envVars.maxCount ? process.env[envVars.maxCount] : undefined; - const maxCountResult = validateMaxCount(maxCountEnvValue, safeOutputConfig.max); - if (!maxCountResult.valid) { - core.setFailed(maxCountResult.error); - return { success: false, reason: "Invalid max count configuration" }; - } - const maxCount = maxCountResult.value; - core.info(`Max count: ${maxCount}`); - - // Get target configuration - const target = envVars.target ? process.env[envVars.target] || "triggering" : "triggering"; - core.info(`${displayName} target configuration: ${target}`); - - // For multiple items, return early without target resolution - if (findMultiple) { - return { - success: true, - items: items, - config: { - allowed, - maxCount, - target, - }, - }; - } - - // Step 5: Resolve target (for single-item processors) - const item = items[0]; - const targetResult = resolveTarget({ - targetConfig: target, - item: item, - context, - itemType: itemTypeName, - // supportsPR in resolveTarget: true=both issue and PR contexts, false=PR-only - // If supportsIssue is true, we pass supportsPR=true to enable both contexts - supportsPR: supportsPR || supportsIssue, - }); - - if (!targetResult.success) { - if (targetResult.shouldFail) { - core.setFailed(targetResult.error); - } else { - core.info(targetResult.error); - } - return { success: false, reason: targetResult.error }; - } - - return { - success: true, - item: item, - config: { - allowed, - maxCount, - target, - }, - targetResult: { - number: targetResult.number, - contextType: targetResult.contextType, - }, - }; - } - - /** - * Get a description of item details for logging - * @param {any} item - The safe output item - * @returns {string|null} Description string or null - */ - function getItemDetails(item) { - if (item.labels && Array.isArray(item.labels)) { - return `${item.labels.length} labels`; - } - if (item.reviewers && Array.isArray(item.reviewers)) { - return `${item.reviewers.length} reviewers`; - } - return null; - } - - /** - * Sanitize and deduplicate an array of string items - * @param {any[]} items - Raw items array - * @returns {string[]} Sanitized and deduplicated array - */ - function sanitizeItems(items) { - return items - .filter(item => item != null && item !== false && item !== 0) - .map(item => String(item).trim()) - .filter(item => item) - .filter((item, index, arr) => arr.indexOf(item) === index); - } - - /** - * Filter items by allowed list - * @param {string[]} items - Items to filter - * @param {string[]|undefined} allowed - Allowed items list (undefined means all allowed) - * @returns {string[]} Filtered items - */ - function filterByAllowed(items, allowed) { - if (!allowed || allowed.length === 0) { - return items; - } - return items.filter(item => allowed.includes(item)); - } - - /** - * Limit items to max count - * @param {string[]} items - Items to limit - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Limited items - */ - function limitToMaxCount(items, maxCount) { - if (items.length > maxCount) { - core.info(`Too many items (${items.length}), limiting to ${maxCount}`); - return items.slice(0, maxCount); - } - return items; - } - - /** - * Process items through the standard pipeline: filter by allowed, sanitize, dedupe, limit - * @param {any[]} rawItems - Raw items array from agent output - * @param {string[]|undefined} allowed - Allowed items list - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Processed items - */ - function processItems(rawItems, allowed, maxCount) { - // Filter by allowed list first - const filtered = filterByAllowed(rawItems, allowed); - - // Sanitize and deduplicate - const sanitized = sanitizeItems(filtered); - - // Limit to max count - return limitToMaxCount(sanitized, maxCount); - } - - module.exports = { - processSafeOutput, - sanitizeItems, - filterByAllowed, - limitToMaxCount, - processItems, - }; - - EOF_8f3864e2 - cat > /tmp/gh-aw/scripts/safe_output_validator.cjs << 'EOF_437e6b4f' - // @ts-check - /// - - const fs = require("fs"); - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - - /** - * Load and parse the safe outputs configuration from config.json - * @returns {object} The parsed configuration object - */ - function loadSafeOutputsConfig() { - const configPath = "/tmp/gh-aw/safeoutputs/config.json"; - try { - if (!fs.existsSync(configPath)) { - core.warning(`Config file not found at ${configPath}, using defaults`); - return {}; - } - const configContent = fs.readFileSync(configPath, "utf8"); - return JSON.parse(configContent); - } catch (error) { - core.warning(`Failed to load config: ${error instanceof Error ? error.message : String(error)}`); - return {}; - } - } - - /** - * Get configuration for a specific safe output type - * @param {string} outputType - The type of safe output (e.g., "add_labels", "update_issue") - * @returns {{max?: number, target?: string, allowed?: string[]}} The configuration for this output type - */ - function getSafeOutputConfig(outputType) { - const config = loadSafeOutputsConfig(); - return config[outputType] || {}; - } - - /** - * Validate and sanitize a title string - * @param {any} title - The title to validate - * @param {string} fieldName - The name of the field for error messages (default: "title") - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateTitle(title, fieldName = "title") { - if (title === undefined || title === null) { - return { valid: false, error: `${fieldName} is required` }; - } - - if (typeof title !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - const trimmed = title.trim(); - if (trimmed.length === 0) { - return { valid: false, error: `${fieldName} cannot be empty` }; - } - - return { valid: true, value: trimmed }; - } - - /** - * Validate and sanitize a body/content string - * @param {any} body - The body to validate - * @param {string} fieldName - The name of the field for error messages (default: "body") - * @param {boolean} required - Whether the body is required (default: false) - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateBody(body, fieldName = "body", required = false) { - if (body === undefined || body === null) { - if (required) { - return { valid: false, error: `${fieldName} is required` }; - } - return { valid: true, value: "" }; - } - - if (typeof body !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - return { valid: true, value: body }; - } - - /** - * Validate and sanitize an array of labels - * @param {any} labels - The labels to validate - * @param {string[]|undefined} allowedLabels - Optional list of allowed labels - * @param {number} maxCount - Maximum number of labels allowed - * @returns {{valid: boolean, value?: string[], error?: string}} Validation result - */ - function validateLabels(labels, allowedLabels = undefined, maxCount = 3) { - if (!labels || !Array.isArray(labels)) { - return { valid: false, error: "labels must be an array" }; - } - - // Check for removal attempts (labels starting with '-') - for (const label of labels) { - if (label && typeof label === "string" && label.startsWith("-")) { - return { valid: false, error: `Label removal is not permitted. Found line starting with '-': ${label}` }; - } - } - - // Filter labels based on allowed list if provided - let validLabels = labels; - if (allowedLabels && allowedLabels.length > 0) { - validLabels = labels.filter(label => allowedLabels.includes(label)); - } - - // Sanitize and deduplicate labels - const uniqueLabels = validLabels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - - // Apply max count limit - if (uniqueLabels.length > maxCount) { - core.info(`Too many labels (${uniqueLabels.length}), limiting to ${maxCount}`); - return { valid: true, value: uniqueLabels.slice(0, maxCount) }; - } - - if (uniqueLabels.length === 0) { - return { valid: false, error: "No valid labels found after sanitization" }; - } - - return { valid: true, value: uniqueLabels }; - } - - /** - * Validate max count from environment variable with config fallback - * @param {string|undefined} envValue - Environment variable value - * @param {number|undefined} configDefault - Default from config.json - * @param {number} [fallbackDefault] - Fallback default for testing (optional, defaults to 1) - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function validateMaxCount(envValue, configDefault, fallbackDefault = 1) { - // Priority: env var > config.json > fallback default - // In production, config.json should always have the default - // Fallback is provided for backward compatibility and testing - const defaultValue = configDefault !== undefined ? configDefault : fallbackDefault; - - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - module.exports = { - loadSafeOutputsConfig, - getSafeOutputConfig, - validateTitle, - validateBody, - validateLabels, - validateMaxCount, - }; - - EOF_437e6b4f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - cat > /tmp/gh-aw/scripts/update_context_helpers.cjs << 'EOF_95d23c7d' - // @ts-check - /// - - /** - * Shared context helper functions for update workflows (issues, pull requests, etc.) - * - * This module provides reusable functions for determining if we're in a valid - * context for updating a specific entity type and extracting entity numbers - * from GitHub event payloads. - * - * @module update_context_helpers - */ - - /** - * Check if the current context is a valid issue context - * @param {string} eventName - GitHub event name - * @param {any} _payload - GitHub event payload (unused but kept for interface consistency) - * @returns {boolean} Whether context is valid for issue updates - */ - function isIssueContext(eventName, _payload) { - return eventName === "issues" || eventName === "issue_comment"; - } - - /** - * Get issue number from the context payload - * @param {any} payload - GitHub event payload - * @returns {number|undefined} Issue number or undefined - */ - function getIssueNumber(payload) { - return payload?.issue?.number; - } - - /** - * Check if the current context is a valid pull request context - * @param {string} eventName - GitHub event name - * @param {any} payload - GitHub event payload - * @returns {boolean} Whether context is valid for PR updates - */ - function isPRContext(eventName, payload) { - const isPR = eventName === "pull_request" || eventName === "pull_request_review" || eventName === "pull_request_review_comment" || eventName === "pull_request_target"; - - // Also check for issue_comment on a PR - const isIssueCommentOnPR = eventName === "issue_comment" && payload?.issue && payload?.issue?.pull_request; - - return isPR || !!isIssueCommentOnPR; - } - - /** - * Get pull request number from the context payload - * @param {any} payload - GitHub event payload - * @returns {number|undefined} PR number or undefined - */ - function getPRNumber(payload) { - if (payload?.pull_request) { - return payload.pull_request.number; - } - // For issue_comment events on PRs, the PR number is in issue.number - if (payload?.issue && payload?.issue?.pull_request) { - return payload.issue.number; - } - return undefined; - } - - module.exports = { - isIssueContext, - getIssueNumber, - isPRContext, - getPRNumber, - }; - - EOF_95d23c7d - cat > /tmp/gh-aw/scripts/update_runner.cjs << 'EOF_006d32d7' - // @ts-check - /// - - /** - * Shared update runner for safe-output scripts (update_issue, update_pull_request, etc.) - * - * This module depends on GitHub Actions environment globals provided by actions/github-script: - * - core: @actions/core module for logging and outputs - * - github: @octokit/rest instance for GitHub API calls - * - context: GitHub Actions context with event payload and repository info - * - * @module update_runner - */ - - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - - /** - * @typedef {Object} UpdateRunnerConfig - * @property {string} itemType - Type of item in agent output (e.g., "update_issue", "update_pull_request") - * @property {string} displayName - Human-readable name (e.g., "issue", "pull request") - * @property {string} displayNamePlural - Human-readable plural name (e.g., "issues", "pull requests") - * @property {string} numberField - Field name for explicit number (e.g., "issue_number", "pull_request_number") - * @property {string} outputNumberKey - Output key for number (e.g., "issue_number", "pull_request_number") - * @property {string} outputUrlKey - Output key for URL (e.g., "issue_url", "pull_request_url") - * @property {(eventName: string, payload: any) => boolean} isValidContext - Function to check if context is valid - * @property {(payload: any) => number|undefined} getContextNumber - Function to get number from context payload - * @property {boolean} supportsStatus - Whether this type supports status updates - * @property {boolean} supportsOperation - Whether this type supports operation (append/prepend/replace) - * @property {(item: any, index: number) => string} renderStagedItem - Function to render item for staged preview - * @property {(github: any, context: any, targetNumber: number, updateData: any) => Promise} executeUpdate - Function to execute the update API call - * @property {(result: any) => string} getSummaryLine - Function to generate summary line for an updated item - */ - - /** - * Resolve the target number for an update operation - * @param {Object} params - Resolution parameters - * @param {string} params.updateTarget - Target configuration ("triggering", "*", or explicit number) - * @param {any} params.item - Update item with optional explicit number field - * @param {string} params.numberField - Field name for explicit number - * @param {boolean} params.isValidContext - Whether current context is valid - * @param {number|undefined} params.contextNumber - Number from triggering context - * @param {string} params.displayName - Display name for error messages - * @returns {{success: true, number: number} | {success: false, error: string}} - */ - function resolveTargetNumber(params) { - const { updateTarget, item, numberField, isValidContext, contextNumber, displayName } = params; - - if (updateTarget === "*") { - // For target "*", we need an explicit number from the update item - const explicitNumber = item[numberField]; - if (explicitNumber) { - const parsed = parseInt(explicitNumber, 10); - if (isNaN(parsed) || parsed <= 0) { - return { success: false, error: `Invalid ${numberField} specified: ${explicitNumber}` }; - } - return { success: true, number: parsed }; - } else { - return { success: false, error: `Target is "*" but no ${numberField} specified in update item` }; - } - } else if (updateTarget && updateTarget !== "triggering") { - // Explicit number specified in target - const parsed = parseInt(updateTarget, 10); - if (isNaN(parsed) || parsed <= 0) { - return { success: false, error: `Invalid ${displayName} number in target configuration: ${updateTarget}` }; - } - return { success: true, number: parsed }; - } else { - // Default behavior: use triggering context - if (isValidContext && contextNumber) { - return { success: true, number: contextNumber }; - } - return { success: false, error: `Could not determine ${displayName} number` }; - } - } - - /** - * Build update data based on allowed fields and provided values - * @param {Object} params - Build parameters - * @param {any} params.item - Update item with field values - * @param {boolean} params.canUpdateStatus - Whether status updates are allowed - * @param {boolean} params.canUpdateTitle - Whether title updates are allowed - * @param {boolean} params.canUpdateBody - Whether body updates are allowed - * @param {boolean} params.supportsStatus - Whether this type supports status - * @returns {{hasUpdates: boolean, updateData: any, logMessages: string[]}} - */ - function buildUpdateData(params) { - const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, supportsStatus } = params; - - /** @type {any} */ - const updateData = {}; - let hasUpdates = false; - const logMessages = []; - - // Handle status update (only for types that support it, like issues) - if (supportsStatus && canUpdateStatus && item.status !== undefined) { - if (item.status === "open" || item.status === "closed") { - updateData.state = item.status; - hasUpdates = true; - logMessages.push(`Will update status to: ${item.status}`); - } else { - logMessages.push(`Invalid status value: ${item.status}. Must be 'open' or 'closed'`); - } - } - - // Handle title update - let titleForDedup = null; - if (canUpdateTitle && item.title !== undefined) { - const trimmedTitle = typeof item.title === "string" ? item.title.trim() : ""; - if (trimmedTitle.length > 0) { - updateData.title = trimmedTitle; - titleForDedup = trimmedTitle; - hasUpdates = true; - logMessages.push(`Will update title to: ${trimmedTitle}`); - } else { - logMessages.push("Invalid title value: must be a non-empty string"); - } - } - - // Handle body update (with title deduplication) - if (canUpdateBody && item.body !== undefined) { - if (typeof item.body === "string") { - let processedBody = item.body; - - // If we're updating the title at the same time, remove duplicate title from body - if (titleForDedup) { - processedBody = removeDuplicateTitleFromDescription(titleForDedup, processedBody); - } - - updateData.body = processedBody; - hasUpdates = true; - logMessages.push(`Will update body (length: ${processedBody.length})`); - } else { - logMessages.push("Invalid body value: must be a string"); - } - } - - return { hasUpdates, updateData, logMessages }; - } - - /** - * Run the update workflow with the provided configuration - * @param {UpdateRunnerConfig} config - Configuration for the update runner - * @returns {Promise} Array of updated items or undefined - */ - async function runUpdateWorkflow(config) { - const { itemType, displayName, displayNamePlural, numberField, outputNumberKey, outputUrlKey, isValidContext, getContextNumber, supportsStatus, supportsOperation, renderStagedItem, executeUpdate, getSummaryLine } = config; - - // Check if we're in staged mode - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - - const result = loadAgentOutput(); - if (!result.success) { - return; - } - - // Find all update items - const updateItems = result.items.filter(/** @param {any} item */ item => item.type === itemType); - if (updateItems.length === 0) { - core.info(`No ${itemType} items found in agent output`); - return; - } - - core.info(`Found ${updateItems.length} ${itemType} item(s)`); - - // If in staged mode, emit step summary instead of updating - if (isStaged) { - await generateStagedPreview({ - title: `Update ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}`, - description: `The following ${displayName} updates would be applied if staged mode was disabled:`, - items: updateItems, - renderItem: renderStagedItem, - }); - return; - } - - // Get the configuration from environment variables - const updateTarget = process.env.GH_AW_UPDATE_TARGET || "triggering"; - const canUpdateStatus = process.env.GH_AW_UPDATE_STATUS === "true"; - const canUpdateTitle = process.env.GH_AW_UPDATE_TITLE === "true"; - const canUpdateBody = process.env.GH_AW_UPDATE_BODY === "true"; - - core.info(`Update target configuration: ${updateTarget}`); - if (supportsStatus) { - core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}`); - } else { - core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}`); - } - - // Check context validity - const contextIsValid = isValidContext(context.eventName, context.payload); - const contextNumber = getContextNumber(context.payload); - - // Validate context based on target configuration - if (updateTarget === "triggering" && !contextIsValid) { - core.info(`Target is "triggering" but not running in ${displayName} context, skipping ${displayName} update`); - return; - } - - const updatedItems = []; - - // Process each update item - for (let i = 0; i < updateItems.length; i++) { - const updateItem = updateItems[i]; - core.info(`Processing ${itemType} item ${i + 1}/${updateItems.length}`); - - // Resolve target number - const targetResult = resolveTargetNumber({ - updateTarget, - item: updateItem, - numberField, - isValidContext: contextIsValid, - contextNumber, - displayName, - }); - - if (!targetResult.success) { - core.info(targetResult.error); - continue; - } - - const targetNumber = targetResult.number; - core.info(`Updating ${displayName} #${targetNumber}`); - - // Build update data - const { hasUpdates, updateData, logMessages } = buildUpdateData({ - item: updateItem, - canUpdateStatus, - canUpdateTitle, - canUpdateBody, - supportsStatus, - }); - - // Log all messages - for (const msg of logMessages) { - core.info(msg); - } - - // Handle body operation for types that support it (like PRs with append/prepend) - if (supportsOperation && canUpdateBody && updateItem.body !== undefined && typeof updateItem.body === "string") { - // The body was already added by buildUpdateData, but we need to handle operations - // This will be handled by the executeUpdate function for PR-specific logic - updateData._operation = updateItem.operation || "append"; - updateData._rawBody = updateItem.body; - } - - if (!hasUpdates) { - core.info("No valid updates to apply for this item"); - continue; - } - - try { - // Execute the update using the provided function - const updatedItem = await executeUpdate(github, context, targetNumber, updateData); - core.info(`Updated ${displayName} #${updatedItem.number}: ${updatedItem.html_url}`); - updatedItems.push(updatedItem); - - // Set output for the last updated item (for backward compatibility) - if (i === updateItems.length - 1) { - core.setOutput(outputNumberKey, updatedItem.number); - core.setOutput(outputUrlKey, updatedItem.html_url); - } - } catch (error) { - core.error(`✗ Failed to update ${displayName} #${targetNumber}: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } - } - - // Write summary for all updated items - if (updatedItems.length > 0) { - let summaryContent = `\n\n## Updated ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}\n`; - for (const item of updatedItems) { - summaryContent += getSummaryLine(item); - } - await core.summary.addRaw(summaryContent).write(); - } - - core.info(`Successfully updated ${updatedItems.length} ${displayName}(s)`); - return updatedItems; - } - - /** - * @typedef {Object} RenderStagedItemConfig - * @property {string} entityName - Display name for the entity (e.g., "Issue", "Pull Request") - * @property {string} numberField - Field name for the target number (e.g., "issue_number", "pull_request_number") - * @property {string} targetLabel - Label for the target (e.g., "Target Issue:", "Target PR:") - * @property {string} currentTargetText - Text when targeting current entity (e.g., "Current issue", "Current pull request") - * @property {boolean} [includeOperation=false] - Whether to include operation field for body updates - */ - - /** - * Create a render function for staged preview items - * @param {RenderStagedItemConfig} config - Configuration for the renderer - * @returns {(item: any, index: number) => string} Render function - */ - function createRenderStagedItem(config) { - const { entityName, numberField, targetLabel, currentTargetText, includeOperation = false } = config; - - return function renderStagedItem(item, index) { - let content = `#### ${entityName} Update ${index + 1}\n`; - if (item[numberField]) { - content += `**${targetLabel}** #${item[numberField]}\n\n`; - } else { - content += `**Target:** ${currentTargetText}\n\n`; - } - - if (item.title !== undefined) { - content += `**New Title:** ${item.title}\n\n`; - } - if (item.body !== undefined) { - if (includeOperation) { - const operation = item.operation || "append"; - content += `**Operation:** ${operation}\n`; - content += `**Body Content:**\n${item.body}\n\n`; - } else { - content += `**New Body:**\n${item.body}\n\n`; - } - } - if (item.status !== undefined) { - content += `**New Status:** ${item.status}\n\n`; - } - return content; - }; - } - - /** - * @typedef {Object} SummaryLineConfig - * @property {string} entityPrefix - Prefix for the summary line (e.g., "Issue", "PR") - */ - - /** - * Create a summary line generator function - * @param {SummaryLineConfig} config - Configuration for the summary generator - * @returns {(item: any) => string} Summary line generator function - */ - function createGetSummaryLine(config) { - const { entityPrefix } = config; - - return function getSummaryLine(item) { - return `- ${entityPrefix} #${item.number}: [${item.title}](${item.html_url})\n`; - }; - } - - module.exports = { - runUpdateWorkflow, - resolveTargetNumber, - buildUpdateData, - createRenderStagedItem, - createGetSummaryLine, - }; - - EOF_006d32d7 - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_ISSUE_TITLE_PREFIX: "[🎭 POEM-BOT] " - GH_AW_ISSUE_LABELS: "poetry,automation,ai-generated" - GH_AW_WORKFLOW_NAME: "Poem Bot - A Creative Agentic Workflow" - GH_AW_ENGINE_ID: "copilot" - GH_AW_ENGINE_MODEL: "gpt-5" - GH_AW_SAFE_OUTPUTS_STAGED: "true" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🪶 *Verses penned by [{workflow_name}]({run_url})*\",\"runStarted\":\"🎭 Hear ye! The muse stirs! [{workflow_name}]({run_url}) takes quill in hand for this {event_type}...\",\"runSuccess\":\"🪶 The poem is writ! [{workflow_name}]({run_url}) has composed verses most fair. Applause! 👏\",\"runFailure\":\"🎭 Alas! [{workflow_name}]({run_url}) {status}. The muse has fled, leaving verses unsung...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function main() { - core.setOutput("issue_number", ""); - core.setOutput("issue_url", ""); - core.setOutput("temporary_id_map", "{}"); - core.setOutput("issues_to_assign_copilot", ""); - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; - } - if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - content += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; - } - return content; - }, - }); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; - } - } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } - } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } - } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; - } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; - } - await core.summary.addRaw(summaryContent).write(); - } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); - } - (async () => { - await main(); - })(); - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Poem Bot - A Creative Agentic Workflow" - GH_AW_ENGINE_ID: "copilot" - GH_AW_ENGINE_MODEL: "gpt-5" - GH_AW_SAFE_OUTPUTS_STAGED: "true" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🪶 *Verses penned by [{workflow_name}]({run_url})*\",\"runStarted\":\"🎭 Hear ye! The muse stirs! [{workflow_name}]({run_url}) takes quill in hand for this {event_type}...\",\"runSuccess\":\"🪶 The poem is writ! [{workflow_name}]({run_url}) has composed verses most fair. Applause! 👏\",\"runFailure\":\"🎭 Alas! [{workflow_name}]({run_url}) {status}. The muse has fled, leaving verses unsung...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } - async function main() { - core.setOutput("discussion_number", ""); - core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; - } - summaryContent += "---\n\n"; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); - return; - } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; - } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; - } - } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } - } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; - } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; - } - } - await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); - } - (async () => { await main(); })(); - - name: Create Pull Request - id: create_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PR_TITLE_PREFIX: "[🎨 POETRY] " - GH_AW_PR_LABELS: "poetry,automation,creative-writing" - GH_AW_PR_DRAFT: "false" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Poem Bot - A Creative Agentic Workflow" - GH_AW_ENGINE_ID: "copilot" - GH_AW_ENGINE_MODEL: "gpt-5" - GH_AW_SAFE_OUTPUTS_STAGED: "true" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🪶 *Verses penned by [{workflow_name}]({run_url})*\",\"runStarted\":\"🎭 Hear ye! The muse stirs! [{workflow_name}]({run_url}) takes quill in hand for this {event_type}...\",\"runSuccess\":\"🪶 The poem is writ! [{workflow_name}]({run_url}) has composed verses most fair. Applause! 👏\",\"runFailure\":\"🎭 Alas! [{workflow_name}]({run_url}) {status}. The muse has fled, leaving verses unsung...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; - } - async function main() { - core.setOutput("pull_request_number", ""); - core.setOutput("pull_request_url", ""); - core.setOutput("issue_number", ""); - core.setOutput("issue_url", ""); - core.setOutput("branch_name", ""); - core.setOutput("fallback_used", ""); - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const workflowId = process.env.GH_AW_WORKFLOW_ID; - if (!workflowId) { - throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); - } - const baseBranch = process.env.GH_AW_BASE_BRANCH; - if (!baseBranch) { - throw new Error("GH_AW_BASE_BRANCH environment variable is required"); - } - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - let outputContent = ""; - if (agentOutputFile.trim() !== "") { - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - } - const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn"; - const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - let patchContent = ""; - let isEmpty = true; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } - if (patchContent.includes("Failed to generate patch")) { - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return; - } - throw new Error(message); - } - core.info("Patch size validation passed"); - } - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to push - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); - return; - } - core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; - } - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); - return; - } - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - if (!title) { - title = "Agent Output"; - } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - const labelsEnv = process.env.GH_AW_PR_LABELS; - const labels = labelsEnv - ? labelsEnv - .split(",") - .map( label => label.trim()) - .filter( label => label) - : []; - const draftEnv = process.env.GH_AW_PR_DRAFT; - const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); - const randomHex = crypto.randomBytes(8).toString("hex"); - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; - } else { - branchName = `${branchName}-${randomHex}`; - core.info(`Using branch name from JSONL with added salt: ${branchName}`); - } - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); - core.info(`Fetching latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${baseBranch}`); - core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`); - await exec.exec(`git checkout -b ${branchName}`); - core.info(`Created new branch from base: ${branchName}`); - if (!isEmpty) { - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - try { - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - try { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Changes pushed to branch"); - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - > [!NOTE] - > This was originally intended as a pull request, but the git push operation failed. - > - > **Workflow Run:** [View run details and download patch artifact](${runUrl}) - > - > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above. - To apply the patch locally: - \`\`\`sh - # Download the artifact from the workflow run ${runUrl} - # (Use GitHub MCP tools if gh CLI is not available) - gh run download ${runId} -n aw.patch - # Apply the patch - git am aw.patch - \`\`\` - ${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - core.setOutput("push_failed", "true"); - await core.summary - .addRaw( - ` - ## Push Failure Fallback - - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} - - **Fallback Issue:** [#${issue.number}](${issue.html_url}) - - **Patch Artifact:** Available in workflow run artifacts - - **Note:** Push failed, created issue as fallback - ` - ) - .write(); - return; - } catch (issueError) { - core.setFailed( - `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` - ); - return; - } - } - } else { - core.info("Skipping patch application (empty patch)"); - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - try { - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Empty branch pushed successfully"); - } catch (pushError) { - core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - return; - } - } else { - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to apply - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - try { - const { data: pullRequest } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }); - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - labels: labels, - }); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } - core.setOutput("pull_request_number", pullRequest.number); - core.setOutput("pull_request_url", pullRequest.html_url); - core.setOutput("branch_name", branchName); - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - await core.summary - .addRaw( - ` - ## Pull Request - - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - - **Branch**: \`${branchName}\` - - **Base Branch**: \`${baseBranch}\` - ` - ) - .write(); - } catch (prError) { - core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); - core.info("Falling back to creating an issue instead"); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). - **Original error:** ${prError instanceof Error ? prError.message : String(prError)} - You can manually create a pull request from the branch if needed.${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - await core.summary - .addRaw( - ` - ## Fallback Issue Created - - **Issue**: [#${issue.number}](${issue.html_url}) - - **Branch**: [\`${branchName}\`](${branchUrl}) - - **Base Branch**: \`${baseBranch}\` - - **Note**: Pull request creation failed, created issue as fallback - ` - ) - .write(); - } catch (issueError) { - core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`); - return; - } - } - } - (async () => { await main(); })(); - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_COMMENT_TARGET: "*" - GH_AW_CREATED_ISSUE_URL: ${{ steps.create_issue.outputs.issue_url }} - GH_AW_CREATED_ISSUE_NUMBER: ${{ steps.create_issue.outputs.issue_number }} - GH_AW_TEMPORARY_ID_MAP: ${{ steps.create_issue.outputs.temporary_id_map }} - GH_AW_CREATED_DISCUSSION_URL: ${{ steps.create_discussion.outputs.discussion_url }} - GH_AW_CREATED_DISCUSSION_NUMBER: ${{ steps.create_discussion.outputs.discussion_number }} - GH_AW_CREATED_PULL_REQUEST_URL: ${{ steps.create_pull_request.outputs.pull_request_url }} - GH_AW_CREATED_PULL_REQUEST_NUMBER: ${{ steps.create_pull_request.outputs.pull_request_number }} - GH_AW_WORKFLOW_NAME: "Poem Bot - A Creative Agentic Workflow" - GH_AW_ENGINE_ID: "copilot" - GH_AW_ENGINE_MODEL: "gpt-5" - GH_AW_SAFE_OUTPUTS_STAGED: "true" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🪶 *Verses penned by [{workflow_name}]({run_url})*\",\"runStarted\":\"🎭 Hear ye! The muse stirs! [{workflow_name}]({run_url}) takes quill in hand for this {event_type}...\",\"runSuccess\":\"🪶 The poem is writ! [{workflow_name}]({run_url}) has composed verses most fair. Applause! 👏\",\"runFailure\":\"🎭 Alas! [{workflow_name}]({run_url}) {status}. The muse has fled, leaving verses unsung...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } - } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); - } - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; - } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; - } - } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); - return; - } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; - } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; - } - } - } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; - } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); - } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); - } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); - } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); - await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; - } - (async () => { await main(); })(); - - name: Close Pull Request - id: close_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'close_pull_request')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Poem Bot - A Creative Agentic Workflow" - GH_AW_ENGINE_ID: "copilot" - GH_AW_ENGINE_MODEL: "gpt-5" - GH_AW_SAFE_OUTPUTS_STAGED: "true" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🪶 *Verses penned by [{workflow_name}]({run_url})*\",\"runStarted\":\"🎭 Hear ye! The muse stirs! [{workflow_name}]({run_url}) takes quill in hand for this {event_type}...\",\"runSuccess\":\"🪶 The poem is writ! [{workflow_name}]({run_url}) has composed verses most fair. Applause! 👏\",\"runFailure\":\"🎭 Alas! [{workflow_name}]({run_url}) {status}. The muse has fled, leaving verses unsung...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { processCloseEntityItems, PULL_REQUEST_CONFIG } = require('/tmp/gh-aw/scripts/close_entity_helpers.cjs'); - async function getPullRequestDetails(github, owner, repo, prNumber) { - const { data: pr } = await github.rest.pulls.get({ - owner, - repo, - pull_number: prNumber, - }); - if (!pr) { - throw new Error(`Pull request #${prNumber} not found in ${owner}/${repo}`); - } - return pr; - } - async function addPullRequestComment(github, owner, repo, prNumber, message) { - const { data: comment } = await github.rest.issues.createComment({ - owner, - repo, - issue_number: prNumber, - body: message, - }); - return comment; - } - async function closePullRequest(github, owner, repo, prNumber) { - const { data: pr } = await github.rest.pulls.update({ - owner, - repo, - pull_number: prNumber, - state: "closed", - }); - return pr; - } - async function main() { - return processCloseEntityItems(PULL_REQUEST_CONFIG, { - getDetails: getPullRequestDetails, - addComment: addPullRequestComment, - closeEntity: closePullRequest, - }); - } - (async () => { await main(); })(); - - name: Create PR Review Comment - id: create_pr_review_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request_review_comment')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PR_REVIEW_COMMENT_SIDE: "RIGHT" - GH_AW_WORKFLOW_NAME: "Poem Bot - A Creative Agentic Workflow" - GH_AW_ENGINE_ID: "copilot" - GH_AW_ENGINE_MODEL: "gpt-5" - GH_AW_SAFE_OUTPUTS_STAGED: "true" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🪶 *Verses penned by [{workflow_name}]({run_url})*\",\"runStarted\":\"🎭 Hear ye! The muse stirs! [{workflow_name}]({run_url}) takes quill in hand for this {event_type}...\",\"runSuccess\":\"🪶 The poem is writ! [{workflow_name}]({run_url}) has composed verses most fair. Applause! 👏\",\"runFailure\":\"🎭 Alas! [{workflow_name}]({run_url}) {status}. The muse has fled, leaving verses unsung...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const reviewCommentItems = result.items.filter( item => item.type === "create_pull_request_review_comment"); - if (reviewCommentItems.length === 0) { - core.info("No create-pull-request-review-comment items found in agent output"); - return; - } - core.info(`Found ${reviewCommentItems.length} create-pull-request-review-comment item(s)`); - if (isStaged) { - await generateStagedPreview({ - title: "Create PR Review Comments", - description: "The following review comments would be created if staged mode was disabled:", - items: reviewCommentItems, - renderItem: (item, index) => { - let content = `#### Review Comment ${index + 1}\n`; - if (item.pull_request_number) { - const repoUrl = getRepositoryUrl(); - const pullUrl = `${repoUrl}/pull/${item.pull_request_number}`; - content += `**Target PR:** [#${item.pull_request_number}](${pullUrl})\n\n`; - } else { - content += `**Target:** Current PR\n\n`; - } - content += `**File:** ${item.path || "No path provided"}\n\n`; - content += `**Line:** ${item.line || "No line provided"}\n\n`; - if (item.start_line) { - content += `**Start Line:** ${item.start_line}\n\n`; - } - content += `**Side:** ${item.side || "RIGHT"}\n\n`; - content += `**Body:**\n${item.body || "No content provided"}\n\n`; - return content; - }, - }); - return; - } - const defaultSide = process.env.GH_AW_PR_REVIEW_COMMENT_SIDE || "RIGHT"; - core.info(`Default comment side configuration: ${defaultSide}`); - const commentTarget = process.env.GH_AW_PR_REVIEW_COMMENT_TARGET || "triggering"; - core.info(`PR review comment target configuration: ${commentTarget}`); - const isPRContext = - context.eventName === "pull_request" || - context.eventName === "pull_request_review" || - context.eventName === "pull_request_review_comment" || - (context.eventName === "issue_comment" && context.payload.issue && context.payload.issue.pull_request); - if (commentTarget === "triggering" && !isPRContext) { - core.info('Target is "triggering" but not running in pull request context, skipping review comment creation'); - return; - } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < reviewCommentItems.length; i++) { - const commentItem = reviewCommentItems[i]; - core.info( - `Processing create-pull-request-review-comment item ${i + 1}/${reviewCommentItems.length}: bodyLength=${commentItem.body ? commentItem.body.length : "undefined"}, path=${commentItem.path}, line=${commentItem.line}, startLine=${commentItem.start_line}` - ); - if (!commentItem.path) { - core.info('Missing required field "path" in review comment item'); - continue; - } - if (!commentItem.line || (typeof commentItem.line !== "number" && typeof commentItem.line !== "string")) { - core.info('Missing or invalid required field "line" in review comment item'); - continue; - } - if (!commentItem.body || typeof commentItem.body !== "string") { - core.info('Missing or invalid required field "body" in review comment item'); - continue; - } - let pullRequestNumber; - let pullRequest; - if (commentTarget === "*") { - if (commentItem.pull_request_number) { - pullRequestNumber = parseInt(commentItem.pull_request_number, 10); - if (isNaN(pullRequestNumber) || pullRequestNumber <= 0) { - core.info(`Invalid pull request number specified: ${commentItem.pull_request_number}`); - continue; - } - } else { - core.info('Target is "*" but no pull_request_number specified in comment item'); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - pullRequestNumber = parseInt(commentTarget, 10); - if (isNaN(pullRequestNumber) || pullRequestNumber <= 0) { - core.info(`Invalid pull request number in target configuration: ${commentTarget}`); - continue; - } - } else { - if (context.payload.pull_request) { - pullRequestNumber = context.payload.pull_request.number; - pullRequest = context.payload.pull_request; - } else if (context.payload.issue && context.payload.issue.pull_request) { - pullRequestNumber = context.payload.issue.number; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } - if (!pullRequestNumber) { - core.info("Could not determine pull request number"); - continue; - } - if (!pullRequest || !pullRequest.head || !pullRequest.head.sha) { - try { - const { data: fullPR } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pullRequestNumber, - }); - pullRequest = fullPR; - core.info(`Fetched full pull request details for PR #${pullRequestNumber}`); - } catch (error) { - core.info(`Failed to fetch pull request details for PR #${pullRequestNumber}: ${error instanceof Error ? error.message : String(error)}`); - continue; - } - } - if (!pullRequest || !pullRequest.head || !pullRequest.head.sha) { - core.info(`Pull request head commit SHA not found for PR #${pullRequestNumber} - cannot create review comment`); - continue; - } - core.info(`Creating review comment on PR #${pullRequestNumber}`); - const line = parseInt(commentItem.line, 10); - if (isNaN(line) || line <= 0) { - core.info(`Invalid line number: ${commentItem.line}`); - continue; - } - let startLine = undefined; - if (commentItem.start_line) { - startLine = parseInt(commentItem.start_line, 10); - if (isNaN(startLine) || startLine <= 0 || startLine > line) { - core.info(`Invalid start_line number: ${commentItem.start_line} (must be <= line: ${line})`); - continue; - } - } - const side = commentItem.side || defaultSide; - if (side !== "LEFT" && side !== "RIGHT") { - core.info(`Invalid side value: ${side} (must be LEFT or RIGHT)`); - continue; - } - let body = commentItem.body.trim(); - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - body += generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - core.info(`Creating review comment on PR #${pullRequestNumber} at ${commentItem.path}:${line}${startLine ? ` (lines ${startLine}-${line})` : ""} [${side}]`); - core.info(`Comment content length: ${body.length}`); - try { - const requestParams = { - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pullRequestNumber, - body: body, - path: commentItem.path, - commit_id: pullRequest && pullRequest.head ? pullRequest.head.sha : "", - line: line, - side: side, - }; - if (startLine !== undefined) { - requestParams.start_line = startLine; - requestParams.start_side = side; - } - const { data: comment } = await github.rest.pulls.createReviewComment(requestParams); - core.info("Created review comment #" + comment.id + ": " + comment.html_url); - createdComments.push(comment); - if (i === reviewCommentItems.length - 1) { - core.setOutput("review_comment_id", comment.id); - core.setOutput("review_comment_url", comment.html_url); - } - } catch (error) { - core.error(`✗ Failed to create review comment: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } - } - if (createdComments.length > 0) { - let summaryContent = "\n\n## GitHub PR Review Comments\n"; - for (const comment of createdComments) { - summaryContent += `- Review Comment #${comment.id}: [View Comment](${comment.html_url})\n`; - } - await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully created ${createdComments.length} review comment(s)`); - return createdComments; - } - (async () => { await main(); })(); - - name: Add Labels - id: add_labels - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_labels')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_LABELS_ALLOWED: "poetry,creative,automation,ai-generated,epic,haiku,sonnet,limerick" - GH_AW_LABELS_MAX_COUNT: 5 - GH_AW_WORKFLOW_NAME: "Poem Bot - A Creative Agentic Workflow" - GH_AW_ENGINE_ID: "copilot" - GH_AW_ENGINE_MODEL: "gpt-5" - GH_AW_SAFE_OUTPUTS_STAGED: "true" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🪶 *Verses penned by [{workflow_name}]({run_url})*\",\"runStarted\":\"🎭 Hear ye! The muse stirs! [{workflow_name}]({run_url}) takes quill in hand for this {event_type}...\",\"runSuccess\":\"🪶 The poem is writ! [{workflow_name}]({run_url}) has composed verses most fair. Applause! 👏\",\"runFailure\":\"🎭 Alas! [{workflow_name}]({run_url}) {status}. The muse has fled, leaving verses unsung...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { processSafeOutput } = require('/tmp/gh-aw/scripts/safe_output_processor.cjs'); - const { validateLabels } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs'); - async function main() { - const result = await processSafeOutput( - { - itemType: "add_labels", - configKey: "add_labels", - displayName: "Labels", - itemTypeName: "label addition", - supportsPR: true, - supportsIssue: true, - envVars: { - allowed: "GH_AW_LABELS_ALLOWED", - maxCount: "GH_AW_LABELS_MAX_COUNT", - target: "GH_AW_LABELS_TARGET", - }, - }, - { - title: "Add Labels", - description: "The following labels would be added if staged mode was disabled:", - renderItem: item => { - let content = ""; - if (item.item_number) { - content += `**Target Issue:** #${item.item_number}\n\n`; - } else { - content += `**Target:** Current issue/PR\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels to add:** ${item.labels.join(", ")}\n\n`; - } - return content; - }, - } - ); - if (!result.success) { - return; - } - const { item: labelsItem, config, targetResult } = result; - if (!config || !targetResult || targetResult.number === undefined) { - core.setFailed("Internal error: config, targetResult, or targetResult.number is undefined"); - return; - } - const { allowed: allowedLabels, maxCount } = config; - const itemNumber = targetResult.number; - const { contextType } = targetResult; - const requestedLabels = labelsItem.labels || []; - core.info(`Requested labels: ${JSON.stringify(requestedLabels)}`); - const labelsResult = validateLabels(requestedLabels, allowedLabels, maxCount); - if (!labelsResult.valid) { - if (labelsResult.error && labelsResult.error.includes("No valid labels")) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` - ## Label Addition - No labels were added (no valid labels found in agent output). - ` - ) - .write(); - return; - } - core.setFailed(labelsResult.error || "Invalid labels"); - return; - } - const uniqueLabels = labelsResult.value || []; - if (uniqueLabels.length === 0) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` - ## Label Addition - No labels were added (no valid labels found in agent output). - ` - ) - .write(); - return; - } - core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${itemNumber}: ${JSON.stringify(uniqueLabels)}`); - try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - labels: uniqueLabels, - }); - core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${itemNumber}`); - core.setOutput("labels_added", uniqueLabels.join("\n")); - const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); - await core.summary - .addRaw( - ` - ## Label Addition - Successfully added ${uniqueLabels.length} label(s) to ${contextType} #${itemNumber}: - ${labelsListMarkdown} - ` - ) - .write(); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to add labels: ${errorMessage}`); - core.setFailed(`Failed to add labels: ${errorMessage}`); - } - } - (async () => { await main(); })(); - - name: Update Issue - id: update_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'update_issue')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Poem Bot - A Creative Agentic Workflow" - GH_AW_ENGINE_ID: "copilot" - GH_AW_ENGINE_MODEL: "gpt-5" - GH_AW_SAFE_OUTPUTS_STAGED: "true" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🪶 *Verses penned by [{workflow_name}]({run_url})*\",\"runStarted\":\"🎭 Hear ye! The muse stirs! [{workflow_name}]({run_url}) takes quill in hand for this {event_type}...\",\"runSuccess\":\"🪶 The poem is writ! [{workflow_name}]({run_url}) has composed verses most fair. Applause! 👏\",\"runFailure\":\"🎭 Alas! [{workflow_name}]({run_url}) {status}. The muse has fled, leaving verses unsung...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { runUpdateWorkflow, createRenderStagedItem, createGetSummaryLine } = require('/tmp/gh-aw/scripts/update_runner.cjs'); - const { isIssueContext, getIssueNumber } = require('/tmp/gh-aw/scripts/update_context_helpers.cjs'); - const renderStagedItem = createRenderStagedItem({ - entityName: "Issue", - numberField: "issue_number", - targetLabel: "Target Issue:", - currentTargetText: "Current issue", - includeOperation: false, - }); - async function executeIssueUpdate(github, context, issueNumber, updateData) { - const { _operation, _rawBody, ...apiData } = updateData; - const { data: issue } = await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - ...apiData, - }); - return issue; - } - const getSummaryLine = createGetSummaryLine({ - entityPrefix: "Issue", - }); - async function main() { - return await runUpdateWorkflow({ - itemType: "update_issue", - displayName: "issue", - displayNamePlural: "issues", - numberField: "issue_number", - outputNumberKey: "issue_number", - outputUrlKey: "issue_url", - isValidContext: isIssueContext, - getContextNumber: getIssueNumber, - supportsStatus: true, - supportsOperation: false, - renderStagedItem, - executeUpdate: executeIssueUpdate, - getSummaryLine, - }); - } - (async () => { await main(); })(); - - name: Push To Pull Request Branch - id: push_to_pull_request_branch - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'push_to_pull_request_branch')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PUSH_IF_NO_CHANGES: "warn" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Poem Bot - A Creative Agentic Workflow" - GH_AW_ENGINE_ID: "copilot" - GH_AW_ENGINE_MODEL: "gpt-5" - GH_AW_SAFE_OUTPUTS_STAGED: "true" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🪶 *Verses penned by [{workflow_name}]({run_url})*\",\"runStarted\":\"🎭 Hear ye! The muse stirs! [{workflow_name}]({run_url}) takes quill in hand for this {event_type}...\",\"runSuccess\":\"🪶 The poem is writ! [{workflow_name}]({run_url}) has composed verses most fair. Applause! 👏\",\"runFailure\":\"🎭 Alas! [{workflow_name}]({run_url}) {status}. The muse has fled, leaving verses unsung...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { updateActivationCommentWithCommit } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - if (agentOutputFile.trim() === "") { - core.info("Agent output content is empty"); - return; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - const target = process.env.GH_AW_PUSH_TARGET || "triggering"; - const ifNoChanges = process.env.GH_AW_PUSH_IF_NO_CHANGES || "warn"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - const message = "No patch file found - cannot push without changes"; - switch (ifNoChanges) { - case "error": - core.setFailed(message); - return; - case "ignore": - return; - case "warn": - default: - core.info(message); - return; - } - } - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchContent.includes("Failed to generate patch")) { - const message = "Patch file contains error message - cannot push without changes"; - core.error("Patch file generation failed - this is an error condition that requires investigation"); - core.error(`Patch file location: /tmp/gh-aw/aw.patch`); - core.error(`Patch file size: ${Buffer.byteLength(patchContent, "utf8")} bytes`); - const previewLength = Math.min(500, patchContent.length); - core.error(`Patch file preview (first ${previewLength} characters):`); - core.error(patchContent.substring(0, previewLength)); - core.setFailed(message); - return; - } - const isEmpty = !patchContent || !patchContent.trim(); - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - core.setFailed(message); - return; - } - core.info("Patch size validation passed"); - } - if (isEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - core.setFailed("No changes to push - failing as configured by if-no-changes: error"); - return; - case "ignore": - break; - case "warn": - default: - core.info(message); - break; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } - core.info(`Target configuration: ${target}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; - } - const pushItem = validatedOutput.items.find( item => item.type === "push_to_pull_request_branch"); - if (!pushItem) { - core.info("No push-to-pull-request-branch item found in agent output"); - return; - } - core.info("Found push-to-pull-request-branch item"); - if (isStaged) { - await generateStagedPreview({ - title: "Push to PR Branch", - description: "The following changes would be pushed if staged mode was disabled:", - items: [{ target, commit_message: pushItem.commit_message }], - renderItem: item => { - let content = ""; - content += `**Target:** ${item.target}\n\n`; - if (item.commit_message) { - content += `**Commit Message:** ${item.commit_message}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - content += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - content += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - content += `**Changes:** No changes (empty patch)\n\n`; - } - } - return content; - }, - }); - return; - } - if (target !== "*" && target !== "triggering") { - const pullNumber = parseInt(target, 10); - if (isNaN(pullNumber)) { - core.setFailed('Invalid target configuration: must be "triggering", "*", or a valid pull request number'); - return; - } - } - let pullNumber; - if (target === "triggering") { - pullNumber = context.payload?.pull_request?.number || context.payload?.issue?.number; - if (!pullNumber) { - core.setFailed('push-to-pull-request-branch with target "triggering" requires pull request context'); - return; - } - } else if (target === "*") { - if (pushItem.pull_number) { - pullNumber = parseInt(pushItem.pull_number, 10); - } - } else { - pullNumber = parseInt(target, 10); - } - let branchName; - let prTitle = ""; - let prLabels = []; - try { - const prInfoRes = await exec.getExecOutput(`gh`, [`pr`, `view`, `${pullNumber}`, `--json`, `headRefName,title,labels`, `--jq`, `{headRefName, title, labels: (.labels // [] | map(.name))}`]); - if (prInfoRes.exitCode === 0) { - const prData = JSON.parse(prInfoRes.stdout.trim()); - branchName = prData.headRefName; - prTitle = prData.title || ""; - prLabels = prData.labels || []; - } else { - throw new Error("No PR data found"); - } - } catch (error) { - core.info(`Warning: Could not fetch PR ${pullNumber} details: ${error instanceof Error ? error.message : String(error)}`); - core.setFailed(`Failed to determine branch name for PR ${pullNumber}`); - return; - } - core.info(`Target branch: ${branchName}`); - core.info(`PR title: ${prTitle}`); - core.info(`PR labels: ${prLabels.join(", ")}`); - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !prTitle.startsWith(titlePrefix)) { - core.setFailed(`Pull request title "${prTitle}" does not start with required prefix "${titlePrefix}"`); - return; - } - const requiredLabelsStr = process.env.GH_AW_PR_LABELS; - if (requiredLabelsStr) { - const requiredLabels = requiredLabelsStr.split(",").map(label => label.trim()); - const missingLabels = requiredLabels.filter(label => !prLabels.includes(label)); - if (missingLabels.length > 0) { - core.setFailed(`Pull request is missing required labels: ${missingLabels.join(", ")}. Current labels: ${prLabels.join(", ")}`); - return; - } - } - if (titlePrefix) { - core.info(`✓ Title prefix validation passed: "${titlePrefix}"`); - } - if (requiredLabelsStr) { - core.info(`✓ Labels validation passed: ${requiredLabelsStr}`); - } - const hasChanges = !isEmpty; - core.info(`Switching to branch: ${branchName}`); - try { - await exec.exec("git fetch origin"); - } catch (fetchError) { - core.setFailed(`Failed to fetch from origin: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`); - return; - } - try { - await exec.exec(`git rev-parse --verify origin/${branchName}`); - } catch (verifyError) { - core.setFailed(`Branch ${branchName} does not exist on origin, can't push to it: ${verifyError instanceof Error ? verifyError.message : String(verifyError)}`); - return; - } - try { - await exec.exec(`git checkout -B ${branchName} origin/${branchName}`); - core.info(`Checked out existing branch from origin: ${branchName}`); - } catch (checkoutError) { - core.setFailed(`Failed to checkout branch ${branchName}: ${checkoutError instanceof Error ? checkoutError.message : String(checkoutError)}`); - return; - } - if (!isEmpty) { - core.info("Applying patch..."); - try { - const commitTitleSuffix = process.env.GH_AW_COMMIT_TITLE_SUFFIX; - if (commitTitleSuffix) { - core.info(`Appending commit title suffix: "${commitTitleSuffix}"`); - let patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchContent = patchContent.replace(/^Subject: (?:\[PATCH\] )?(.*)$/gm, (match, title) => `Subject: [PATCH] ${title}${commitTitleSuffix}`); - fs.writeFileSync("/tmp/gh-aw/aw.patch", patchContent, "utf8"); - core.info(`Patch modified with commit title suffix: "${commitTitleSuffix}"`); - } - const finalPatchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - const patchLines = finalPatchContent.split("\n"); - const previewLineCount = Math.min(100, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - await exec.exec(`git push origin ${branchName}`); - core.info(`Changes committed and pushed to branch: ${branchName}`); - } catch (error) { - core.error(`Failed to apply patch: ${error instanceof Error ? error.message : String(error)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const logResult = await exec.getExecOutput("git", ["log", "--oneline", "-5"]); - core.info("Recent commits (last 5):"); - core.info(logResult.stdout); - const diffResult = await exec.getExecOutput("git", ["diff", "HEAD"]); - core.info("Uncommitted changes:"); - core.info(diffResult.stdout && diffResult.stdout.trim() ? diffResult.stdout : "(no uncommitted changes)"); - const patchDiffResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch diff:"); - core.info(patchDiffResult.stdout); - const patchFullResult = await exec.getExecOutput("git", ["am", "--show-current-patch"]); - core.info("Failed patch (full):"); - core.info(patchFullResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - } else { - core.info("Skipping patch application (empty patch)"); - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - core.setFailed("No changes to apply - failing as configured by if-no-changes: error"); - return; - case "ignore": - break; - case "warn": - default: - core.info(message); - break; - } - } - const commitShaRes = await exec.getExecOutput("git", ["rev-parse", "HEAD"]); - if (commitShaRes.exitCode !== 0) throw new Error("Failed to get commit SHA"); - const commitSha = commitShaRes.stdout.trim(); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const repoUrl = context.payload.repository ? context.payload.repository.html_url : `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - const pushUrl = `${repoUrl}/tree/${branchName}`; - const commitUrl = `${repoUrl}/commit/${commitSha}`; - core.setOutput("branch_name", branchName); - core.setOutput("commit_sha", commitSha); - core.setOutput("push_url", pushUrl); - core.setOutput("commit_url", commitUrl); - if (hasChanges) { - await updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl); - } - const summaryTitle = hasChanges ? "Push to Branch" : "Push to Branch (No Changes)"; - const summaryContent = hasChanges - ? ` - ## ${summaryTitle} - - **Branch**: \`${branchName}\` - - **Commit**: [${commitSha.substring(0, 7)}](${commitUrl}) - - **URL**: [${pushUrl}](${pushUrl}) - ` - : ` - ## ${summaryTitle} - - **Branch**: \`${branchName}\` - - **Status**: No changes to apply (noop operation) - - **URL**: [${pushUrl}](${pushUrl}) - `; - await core.summary.addRaw(summaryContent).write(); - } - (async () => { await main(); })(); - - name: Upload Assets - id: upload_assets - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'upload_asset')) + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Poem Bot - A Creative Agentic Workflow" - GH_AW_ENGINE_ID: "copilot" - GH_AW_ENGINE_MODEL: "gpt-5" - GH_AW_SAFE_OUTPUTS_STAGED: "true" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🪶 *Verses penned by [{workflow_name}]({run_url})*\",\"runStarted\":\"🎭 Hear ye! The muse stirs! [{workflow_name}]({run_url}) takes quill in hand for this {event_type}...\",\"runSuccess\":\"🪶 The poem is writ! [{workflow_name}]({run_url}) has composed verses most fair. Applause! 👏\",\"runFailure\":\"🎭 Alas! [{workflow_name}]({run_url}) {status}. The muse has fled, leaving verses unsung...\"}" - with: + GH_AW_ISSUE_TITLE_PREFIX: "[🎭 POEM-BOT] " + GH_AW_ISSUE_LABELS: "poetry,automation,ai-generated" + GH_AW_DISCUSSION_CATEGORY: "General" + GH_AW_DISCUSSION_TITLE_PREFIX: "[📜 POETRY] " +GH_AW_CLOSE_OLDER_DISCUSSIONS=true GH_AW_PR_LABELS: "poetry,automation,creative-writing" + GH_AW_PR_TITLE_PREFIX: "[🎨 POETRY] " +GH_AW_PR_DRAFT=false with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const path = require("path"); - const crypto = require("crypto"); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function normalizeBranchName(branchName) { - if (!branchName || typeof branchName !== "string" || branchName.trim() === "") { - return branchName; - } - let normalized = branchName.replace(/[^a-zA-Z0-9\-_/.]+/g, "-"); - normalized = normalized.replace(/-+/g, "-"); - normalized = normalized.replace(/^-+|-+$/g, ""); - if (normalized.length > 128) { - normalized = normalized.substring(0, 128); - } - normalized = normalized.replace(/-+$/, ""); - normalized = normalized.toLowerCase(); - return normalized; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const branchName = process.env.GH_AW_ASSETS_BRANCH; - if (!branchName || typeof branchName !== "string") { - core.setFailed("GH_AW_ASSETS_BRANCH environment variable is required but not set"); - return; - } - const normalizedBranchName = normalizeBranchName(branchName); - core.info(`Using assets branch: ${normalizedBranchName}`); const result = loadAgentOutput(); if (!result.success) { - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - const uploadItems = result.items.filter( item => item.type === "upload_assets"); - const uploadAssetItems = result.items.filter( item => item.type === "upload_asset"); - const allUploadItems = [...uploadItems, ...uploadAssetItems]; - if (allUploadItems.length === 0) { - core.info("No upload-asset items found in agent output"); - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); + core.info("No agent output to process"); return; } - core.info(`Found ${allUploadItems.length} upload-asset item(s)`); - let uploadCount = 0; - let hasChanges = false; - try { - try { - await exec.exec(`git rev-parse --verify origin/${normalizedBranchName}`); - await exec.exec(`git checkout -B ${normalizedBranchName} origin/${normalizedBranchName}`); - core.info(`Checked out existing branch from origin: ${normalizedBranchName}`); - } catch (originError) { - if (!normalizedBranchName.startsWith("assets/")) { - core.setFailed( - `Branch '${normalizedBranchName}' does not start with the required 'assets/' prefix. ` + - `Orphaned branches can only be automatically created under the 'assets/' prefix. ` + - `Please create the branch manually first, or use a branch name starting with 'assets/'.` - ); - return; - } - core.info(`Creating new orphaned branch: ${normalizedBranchName}`); - await exec.exec(`git checkout --orphan ${normalizedBranchName}`); - await exec.exec(`git rm -rf .`); - await exec.exec(`git clean -fdx`); - } - for (const asset of uploadAssetItems) { - try { - const { fileName, sha, size, targetFileName } = asset; - if (!fileName || !sha || !targetFileName) { - core.error(`Invalid asset entry missing required fields: ${JSON.stringify(asset)}`); - continue; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - const assetSourcePath = path.join("/tmp/gh-aw/safeoutputs/assets", fileName); - if (!fs.existsSync(assetSourcePath)) { - core.warning(`Asset file not found: ${assetSourcePath}`); - continue; + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } - const fileContent = fs.readFileSync(assetSourcePath); - const computedSha = crypto.createHash("sha256").update(fileContent).digest("hex"); - if (computedSha !== sha) { - core.warning(`SHA mismatch for ${fileName}: expected ${sha}, got ${computedSha}`); - continue; + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; } - if (fs.existsSync(targetFileName)) { - core.info(`Asset ${targetFileName} already exists, skipping`); - continue; + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - fs.copyFileSync(assetSourcePath, targetFileName); - await exec.exec(`git add "${targetFileName}"`); - uploadCount++; - hasChanges = true; - core.info(`Added asset: ${targetFileName} (${size} bytes)`); - } catch (error) { - core.warning(`Failed to process asset ${asset.fileName}: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (hasChanges) { - const commitMessage = `[skip-ci] Add ${uploadCount} asset(s)`; - await exec.exec(`git`, [`commit`, `-m`, commitMessage]); - if (isStaged) { - core.summary.addRaw("## Staged Asset Publication"); - } else { - await exec.exec(`git push origin ${normalizedBranchName}`); - core.summary.addRaw("## Assets").addRaw(`Successfully uploaded **${uploadCount}** assets to branch \`${normalizedBranchName}\``).addRaw(""); - core.info(`Successfully uploaded ${uploadCount} assets to branch ${normalizedBranchName}`); - } - for (const asset of uploadAssetItems) { - if (asset.fileName && asset.sha && asset.size && asset.url) { - core.summary.addRaw(`- [\`${asset.fileName}\`](${asset.url}) → \`${asset.targetFileName}\` (${asset.size} bytes)`); + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } + summaryContent += "---\n\n"; } - core.summary.write(); - } else { - core.info("No new assets to upload"); } - } catch (error) { - core.setFailed(`Failed to upload assets: ${error instanceof Error ? error.message : String(error)}`); - return; - } - core.setOutput("upload_count", uploadCount.toString()); - core.setOutput("branch_name", normalizedBranchName); - } - (async () => { await main(); })(); - - name: Link Sub Issue - id: link_sub_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'link_sub_issue')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_CREATED_ISSUE_NUMBER: ${{ steps.create_issue.outputs.issue_number }} - GH_AW_TEMPORARY_ID_MAP: ${{ steps.create_issue.outputs.temporary_id_map }} - GH_AW_WORKFLOW_NAME: "Poem Bot - A Creative Agentic Workflow" - GH_AW_ENGINE_ID: "copilot" - GH_AW_ENGINE_MODEL: "gpt-5" - GH_AW_SAFE_OUTPUTS_STAGED: "true" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🪶 *Verses penned by [{workflow_name}]({run_url})*\",\"runStarted\":\"🎭 Hear ye! The muse stirs! [{workflow_name}]({run_url}) takes quill in hand for this {event_type}...\",\"runSuccess\":\"🪶 The poem is writ! [{workflow_name}]({run_url}) has composed verses most fair. Applause! 👏\",\"runFailure\":\"🎭 Alas! [{workflow_name}]({run_url}) {status}. The muse has fled, leaving verses unsung...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { loadTemporaryIdMap, resolveIssueNumber } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - async function main() { - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const linkItems = result.items.filter(item => item.type === "link_sub_issue"); - if (linkItems.length === 0) { - core.info("No link_sub_issue items found in agent output"); - return; - } - core.info(`Found ${linkItems.length} link_sub_issue item(s)`); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - await generateStagedPreview({ - title: "Link Sub-Issue", - description: "The following sub-issue links would be created if staged mode was disabled:", - items: linkItems, - renderItem: item => { - const parentResolved = resolveIssueNumber(item.parent_issue_number, temporaryIdMap); - const subResolved = resolveIssueNumber(item.sub_issue_number, temporaryIdMap); - let parentDisplay = parentResolved.resolved ? `${parentResolved.resolved.repo}#${parentResolved.resolved.number}` : `${item.parent_issue_number} (unresolved)`; - let subDisplay = subResolved.resolved ? `${subResolved.resolved.repo}#${subResolved.resolved.number}` : `${item.sub_issue_number} (unresolved)`; - if (parentResolved.wasTemporaryId && parentResolved.resolved) { - parentDisplay += ` (from ${item.parent_issue_number})`; - } - if (subResolved.wasTemporaryId && subResolved.resolved) { - subDisplay += ` (from ${item.sub_issue_number})`; - } - let content = `**Parent Issue:** ${parentDisplay}\n`; - content += `**Sub-Issue:** ${subDisplay}\n\n`; - return content; - }, - }); - return; - } - const parentRequiredLabelsEnv = process.env.GH_AW_LINK_SUB_ISSUE_PARENT_REQUIRED_LABELS?.trim(); - const parentRequiredLabels = parentRequiredLabelsEnv - ? parentRequiredLabelsEnv - .split(",") - .map(l => l.trim()) - .filter(l => l) - : []; - const parentTitlePrefix = process.env.GH_AW_LINK_SUB_ISSUE_PARENT_TITLE_PREFIX?.trim() || ""; - const subRequiredLabelsEnv = process.env.GH_AW_LINK_SUB_ISSUE_SUB_REQUIRED_LABELS?.trim(); - const subRequiredLabels = subRequiredLabelsEnv - ? subRequiredLabelsEnv - .split(",") - .map(l => l.trim()) - .filter(l => l) - : []; - const subTitlePrefix = process.env.GH_AW_LINK_SUB_ISSUE_SUB_TITLE_PREFIX?.trim() || ""; - if (parentRequiredLabels.length > 0) { - core.info(`Parent required labels: ${JSON.stringify(parentRequiredLabels)}`); - } - if (parentTitlePrefix) { - core.info(`Parent title prefix: ${parentTitlePrefix}`); - } - if (subRequiredLabels.length > 0) { - core.info(`Sub-issue required labels: ${JSON.stringify(subRequiredLabels)}`); - } - if (subTitlePrefix) { - core.info(`Sub-issue title prefix: ${subTitlePrefix}`); - } - const maxCountEnv = process.env.GH_AW_LINK_SUB_ISSUE_MAX_COUNT; - const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 5; - if (isNaN(maxCount) || maxCount < 1) { - core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - core.info(`Max count: ${maxCount}`); - const itemsToProcess = linkItems.slice(0, maxCount); - if (linkItems.length > maxCount) { - core.warning(`Found ${linkItems.length} link_sub_issue items, but max is ${maxCount}. Processing first ${maxCount}.`); + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const results = []; - for (const item of itemsToProcess) { - const parentResolved = resolveIssueNumber(item.parent_issue_number, temporaryIdMap); - const subResolved = resolveIssueNumber(item.sub_issue_number, temporaryIdMap); - if (parentResolved.errorMessage) { - core.warning(`Failed to resolve parent issue: ${parentResolved.errorMessage}`); - results.push({ - parent_issue_number: item.parent_issue_number, - sub_issue_number: item.sub_issue_number, - success: false, - error: parentResolved.errorMessage, - }); - continue; - } - if (subResolved.errorMessage) { - core.warning(`Failed to resolve sub-issue: ${subResolved.errorMessage}`); - results.push({ - parent_issue_number: item.parent_issue_number, - sub_issue_number: item.sub_issue_number, - success: false, - error: subResolved.errorMessage, - }); - continue; - } - const parentIssueNumber = parentResolved.resolved.number; - const subIssueNumber = subResolved.resolved.number; - if (parentResolved.wasTemporaryId) { - core.info(`Resolved parent temporary ID '${item.parent_issue_number}' to ${parentResolved.resolved.repo}#${parentIssueNumber}`); - } - if (subResolved.wasTemporaryId) { - core.info(`Resolved sub-issue temporary ID '${item.sub_issue_number}' to ${subResolved.resolved.repo}#${subIssueNumber}`); - } - let parentIssue; - try { - const parentResponse = await github.rest.issues.get({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - }); - parentIssue = parentResponse.data; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.warning(`Failed to fetch parent issue #${parentIssueNumber}: ${errorMessage}`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: false, - error: `Failed to fetch parent issue: ${errorMessage}`, - }); - continue; - } - if (parentRequiredLabels.length > 0) { - const parentLabels = parentIssue.labels.map(l => (typeof l === "string" ? l : l.name || "")); - const missingLabels = parentRequiredLabels.filter(required => !parentLabels.includes(required)); - if (missingLabels.length > 0) { - core.warning(`Parent issue #${parentIssueNumber} is missing required labels: ${missingLabels.join(", ")}. Skipping.`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: false, - error: `Parent issue missing required labels: ${missingLabels.join(", ")}`, - }); - continue; - } - } - if (parentTitlePrefix && !parentIssue.title.startsWith(parentTitlePrefix)) { - core.warning(`Parent issue #${parentIssueNumber} title does not start with "${parentTitlePrefix}". Skipping.`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: false, - error: `Parent issue title does not start with "${parentTitlePrefix}"`, - }); - continue; - } - let subIssue; - try { - const subResponse = await github.rest.issues.get({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: subIssueNumber, - }); - subIssue = subResponse.data; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to fetch sub-issue #${subIssueNumber}: ${errorMessage}`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: false, - error: `Failed to fetch sub-issue: ${errorMessage}`, - }); - continue; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); } - try { - const parentCheckQuery = ` - query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $number) { - parent { - number - title - } - } - } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - `; - const parentCheckResult = await github.graphql(parentCheckQuery, { - owner: context.repo.owner, - repo: context.repo.repo, - number: subIssueNumber, - }); - const existingParent = parentCheckResult?.repository?.issue?.parent; - if (existingParent) { - core.warning(`Sub-issue #${subIssueNumber} is already a sub-issue of #${existingParent.number} ("${existingParent.title}"). Skipping.`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: false, - error: `Sub-issue is already a sub-issue of #${existingParent.number}`, - }); - continue; - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.warning(`Could not check if sub-issue #${subIssueNumber} has a parent: ${errorMessage}. Proceeding with link attempt.`); - } - if (subRequiredLabels.length > 0) { - const subLabels = subIssue.labels.map(l => (typeof l === "string" ? l : l.name || "")); - const missingLabels = subRequiredLabels.filter(required => !subLabels.includes(required)); - if (missingLabels.length > 0) { - core.warning(`Sub-issue #${subIssueNumber} is missing required labels: ${missingLabels.join(", ")}. Skipping.`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: false, - error: `Sub-issue missing required labels: ${missingLabels.join(", ")}`, - }); - continue; } } - if (subTitlePrefix && !subIssue.title.startsWith(subTitlePrefix)) { - core.warning(`Sub-issue #${subIssueNumber} title does not start with "${subTitlePrefix}". Skipping.`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: false, - error: `Sub-issue title does not start with "${subTitlePrefix}"`, - }); - continue; - } - try { - const parentNodeId = parentIssue.node_id; - const subNodeId = subIssue.node_id; - await github.graphql( - ` - mutation AddSubIssue($parentId: ID!, $subIssueId: ID!) { - addSubIssue(input: { issueId: $parentId, subIssueId: $subIssueId }) { - issue { - id - number - } - subIssue { - id - number - } - } - } - `, - { - parentId: parentNodeId, - subIssueId: subNodeId, - } - ); - core.info(`Successfully linked issue #${subIssueNumber} as sub-issue of #${parentIssueNumber}`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: true, - }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.warning(`Failed to link issue #${subIssueNumber} as sub-issue of #${parentIssueNumber}: ${errorMessage}`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: false, - error: errorMessage, - }); - } - } - const successCount = results.filter(r => r.success).length; - const failureCount = results.filter(r => !r.success).length; - let summaryContent = "## Link Sub-Issue\n\n"; - if (successCount > 0) { - summaryContent += `✅ Successfully linked ${successCount} sub-issue(s):\n\n`; - for (const result of results.filter(r => r.success)) { - summaryContent += `- Issue #${result.sub_issue_number} → Parent #${result.parent_issue_number}\n`; - } - summaryContent += "\n"; - } - if (failureCount > 0) { - summaryContent += `⚠️ Failed to link ${failureCount} sub-issue(s):\n\n`; - for (const result of results.filter(r => !r.success)) { - summaryContent += `- Issue #${result.sub_issue_number} → Parent #${result.parent_issue_number}: ${result.error}\n`; - } - } - await core.summary.addRaw(summaryContent).write(); - const linkedIssues = results - .filter(r => r.success) - .map(r => `${r.parent_issue_number}:${r.sub_issue_number}`) - .join("\n"); - core.setOutput("linked_issues", linkedIssues); - if (failureCount > 0) { - core.warning(`Failed to link ${failureCount} sub-issue(s). See step summary for details.`); - } - } - (async () => { await main(); })(); - - name: Create Agent Task - id: create_agent_task - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_agent_task')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Poem Bot - A Creative Agentic Workflow" - GH_AW_ENGINE_ID: "copilot" - GH_AW_ENGINE_MODEL: "gpt-5" - GH_AW_SAFE_OUTPUTS_STAGED: "true" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🪶 *Verses penned by [{workflow_name}]({run_url})*\",\"runStarted\":\"🎭 Hear ye! The muse stirs! [{workflow_name}]({run_url}) takes quill in hand for this {event_type}...\",\"runSuccess\":\"🪶 The poem is writ! [{workflow_name}]({run_url}) has composed verses most fair. Applause! 👏\",\"runFailure\":\"🎭 Alas! [{workflow_name}]({run_url}) {status}. The muse has fled, leaving verses unsung...\"}" - with: - github-token: ${{ secrets.COPILOT_GITHUB_TOKEN || secrets.GH_AW_GITHUB_TOKEN }} - script: | - const fs = require("fs"); - const path = require("path"); - async function main() { - core.setOutput("task_number", ""); - core.setOutput("task_url", ""); - const isStaged = process.env.GITHUB_AW_SAFE_OUTPUTS_STAGED === "true"; - const agentOutputFile = process.env.GITHUB_AW_AGENT_OUTPUT; - if (!agentOutputFile) { - core.info("No GITHUB_AW_AGENT_OUTPUT environment variable found"); - return; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - core.info(`Agent output content length: ${outputContent.length}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; - } - const createAgentTaskItems = validatedOutput.items.filter(item => item.type === "create_agent_task"); - if (createAgentTaskItems.length === 0) { - core.info("No create-agent-task items found in agent output"); - return; } - core.info(`Found ${createAgentTaskItems.length} create-agent-task item(s)`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Agent Tasks Preview\n\n"; - summaryContent += "The following agent tasks would be created if staged mode was disabled:\n\n"; - for (const [index, item] of createAgentTaskItems.entries()) { - summaryContent += `### Task ${index + 1}\n\n`; - summaryContent += `**Description:**\n${item.body || "No description provided"}\n\n`; - const baseBranch = process.env.GITHUB_AW_AGENT_TASK_BASE || "main"; - summaryContent += `**Base Branch:** ${baseBranch}\n\n`; - const targetRepo = process.env.GITHUB_AW_TARGET_REPO || process.env.GITHUB_REPOSITORY || "unknown"; - summaryContent += `**Target Repository:** ${targetRepo}\n\n`; - summaryContent += "---\n\n"; + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } - core.info(summaryContent); - core.summary.addRaw(summaryContent); - await core.summary.write(); - return; } - const baseBranch = process.env.GITHUB_AW_AGENT_TASK_BASE || process.env.GITHUB_REF_NAME || "main"; - const targetRepo = process.env.GITHUB_AW_TARGET_REPO; - const createdTasks = []; - let summaryContent = "## ✅ Agent Tasks Created\n\n"; - for (const [index, taskItem] of createAgentTaskItems.entries()) { - const taskDescription = taskItem.body; - if (!taskDescription || taskDescription.trim() === "") { - core.warning(`Task ${index + 1}: Agent task description is empty, skipping`); - continue; - } - try { - const tmpDir = "/tmp/gh-aw"; - if (!fs.existsSync(tmpDir)) { - fs.mkdirSync(tmpDir, { recursive: true }); - } - const taskFile = path.join(tmpDir, `agent-task-description-${index + 1}.md`); - fs.writeFileSync(taskFile, taskDescription, "utf8"); - core.info(`Task ${index + 1}: Task description written to ${taskFile}`); - const ghArgs = ["agent-task", "create", "--from-file", taskFile, "--base", baseBranch]; - if (targetRepo) { - ghArgs.push("--repo", targetRepo); - } - core.info(`Task ${index + 1}: Creating agent task with command: gh ${ghArgs.join(" ")}`); - let taskOutput; - try { - taskOutput = await exec.getExecOutput("gh", ghArgs, { - silent: false, - ignoreReturnCode: false, - }); - } catch (execError) { - const errorMessage = execError instanceof Error ? execError.message : String(execError); - if (errorMessage.includes("authentication") || errorMessage.includes("permission") || errorMessage.includes("forbidden") || errorMessage.includes("401") || errorMessage.includes("403")) { - core.error(`Task ${index + 1}: Failed to create agent task due to authentication/permission error.`); - core.error(`The default GITHUB_TOKEN does not have permission to create agent tasks.`); - core.error(`You must configure a Personal Access Token (PAT) as COPILOT_GITHUB_TOKEN or GH_AW_GITHUB_TOKEN.`); - core.error(`See documentation: https://githubnext.github.io/gh-aw/reference/safe-outputs/#agent-task-creation-create-agent-task`); - } else { - core.error(`Task ${index + 1}: Failed to create agent task: ${errorMessage}`); - } - continue; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - const output = taskOutput.stdout.trim(); - core.info(`Task ${index + 1}: Agent task created: ${output}`); - const urlMatch = output.match(/github\.com\/[^/]+\/[^/]+\/issues\/(\d+)/); - if (urlMatch) { - const taskNumber = urlMatch[1]; - createdTasks.push({ number: taskNumber, url: output }); - summaryContent += `### Task ${index + 1}\n\n`; - summaryContent += `**Task:** [#${taskNumber}](${output})\n\n`; - summaryContent += `**Base Branch:** ${baseBranch}\n\n`; - core.info(`✅ Successfully created agent task #${taskNumber}`); - } else { - core.warning(`Task ${index + 1}: Could not parse task number from output: ${output}`); - createdTasks.push({ number: "", url: output }); + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } - } catch (error) { - core.error(`Task ${index + 1}: Error creating agent task: ${error instanceof Error ? error.message : String(error)}`); } + await core.summary.addRaw(summaryContent).write(); } - if (createdTasks.length > 0) { - core.setOutput("task_number", createdTasks[0].number); - core.setOutput("task_url", createdTasks[0].url); - } else { - core.setFailed("No agent tasks were created"); - return; - } - core.info(summaryContent); - core.summary.addRaw(summaryContent); - await core.summary.write(); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - main().catch(error => { - core.setFailed(error instanceof Error ? error.message : String(error)); - }); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); update_cache_memory: needs: diff --git a/.github/workflows/portfolio-analyst.lock.yml b/.github/workflows/portfolio-analyst.lock.yml index 50f3c60d4f..78afad62ab 100644 --- a/.github/workflows/portfolio-analyst.lock.yml +++ b/.github/workflows/portfolio-analyst.lock.yml @@ -7607,8 +7607,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7621,1313 +7629,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Automated Portfolio Analyst" - GH_AW_TRACKER_ID: "portfolio-analyst-weekly" - GH_AW_ENGINE_ID: "copilot" - with: + GH_AW_DISCUSSION_CATEGORY: "Audits" + GH_AW_DISCUSSION_TITLE_PREFIX: "[portfolio] " +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; } } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); - - name: Upload Assets - id: upload_assets - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'upload_asset')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Automated Portfolio Analyst" - GH_AW_TRACKER_ID: "portfolio-analyst-weekly" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const path = require("path"); - const crypto = require("crypto"); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function normalizeBranchName(branchName) { - if (!branchName || typeof branchName !== "string" || branchName.trim() === "") { - return branchName; - } - let normalized = branchName.replace(/[^a-zA-Z0-9\-_/.]+/g, "-"); - normalized = normalized.replace(/-+/g, "-"); - normalized = normalized.replace(/^-+|-+$/g, ""); - if (normalized.length > 128) { - normalized = normalized.substring(0, 128); - } - normalized = normalized.replace(/-+$/, ""); - normalized = normalized.toLowerCase(); - return normalized; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const branchName = process.env.GH_AW_ASSETS_BRANCH; - if (!branchName || typeof branchName !== "string") { - core.setFailed("GH_AW_ASSETS_BRANCH environment variable is required but not set"); - return; - } - const normalizedBranchName = normalizeBranchName(branchName); - core.info(`Using assets branch: ${normalizedBranchName}`); - const result = loadAgentOutput(); - if (!result.success) { - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - const uploadItems = result.items.filter( item => item.type === "upload_assets"); - const uploadAssetItems = result.items.filter( item => item.type === "upload_asset"); - const allUploadItems = [...uploadItems, ...uploadAssetItems]; - if (allUploadItems.length === 0) { - core.info("No upload-asset items found in agent output"); - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - core.info(`Found ${allUploadItems.length} upload-asset item(s)`); - let uploadCount = 0; - let hasChanges = false; + (async () => { try { - try { - await exec.exec(`git rev-parse --verify origin/${normalizedBranchName}`); - await exec.exec(`git checkout -B ${normalizedBranchName} origin/${normalizedBranchName}`); - core.info(`Checked out existing branch from origin: ${normalizedBranchName}`); - } catch (originError) { - if (!normalizedBranchName.startsWith("assets/")) { - core.setFailed( - `Branch '${normalizedBranchName}' does not start with the required 'assets/' prefix. ` + - `Orphaned branches can only be automatically created under the 'assets/' prefix. ` + - `Please create the branch manually first, or use a branch name starting with 'assets/'.` - ); - return; - } - core.info(`Creating new orphaned branch: ${normalizedBranchName}`); - await exec.exec(`git checkout --orphan ${normalizedBranchName}`); - await exec.exec(`git rm -rf .`); - await exec.exec(`git clean -fdx`); - } - for (const asset of uploadAssetItems) { - try { - const { fileName, sha, size, targetFileName } = asset; - if (!fileName || !sha || !targetFileName) { - core.error(`Invalid asset entry missing required fields: ${JSON.stringify(asset)}`); - continue; - } - const assetSourcePath = path.join("/tmp/gh-aw/safeoutputs/assets", fileName); - if (!fs.existsSync(assetSourcePath)) { - core.warning(`Asset file not found: ${assetSourcePath}`); - continue; - } - const fileContent = fs.readFileSync(assetSourcePath); - const computedSha = crypto.createHash("sha256").update(fileContent).digest("hex"); - if (computedSha !== sha) { - core.warning(`SHA mismatch for ${fileName}: expected ${sha}, got ${computedSha}`); - continue; - } - if (fs.existsSync(targetFileName)) { - core.info(`Asset ${targetFileName} already exists, skipping`); - continue; - } - fs.copyFileSync(assetSourcePath, targetFileName); - await exec.exec(`git add "${targetFileName}"`); - uploadCount++; - hasChanges = true; - core.info(`Added asset: ${targetFileName} (${size} bytes)`); - } catch (error) { - core.warning(`Failed to process asset ${asset.fileName}: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (hasChanges) { - const commitMessage = `[skip-ci] Add ${uploadCount} asset(s)`; - await exec.exec(`git`, [`commit`, `-m`, commitMessage]); - if (isStaged) { - core.summary.addRaw("## Staged Asset Publication"); - } else { - await exec.exec(`git push origin ${normalizedBranchName}`); - core.summary.addRaw("## Assets").addRaw(`Successfully uploaded **${uploadCount}** assets to branch \`${normalizedBranchName}\``).addRaw(""); - core.info(`Successfully uploaded ${uploadCount} assets to branch ${normalizedBranchName}`); - } - for (const asset of uploadAssetItems) { - if (asset.fileName && asset.sha && asset.size && asset.url) { - core.summary.addRaw(`- [\`${asset.fileName}\`](${asset.url}) → \`${asset.targetFileName}\` (${asset.size} bytes)`); - } - } - core.summary.write(); - } else { - core.info("No new assets to upload"); - } + await main(); } catch (error) { - core.setFailed(`Failed to upload assets: ${error instanceof Error ? error.message : String(error)}`); - return; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - core.setOutput("upload_count", uploadCount.toString()); - core.setOutput("branch_name", normalizedBranchName); - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/pr-nitpick-reviewer.lock.yml b/.github/workflows/pr-nitpick-reviewer.lock.yml index ff6c6bae0e..1f06626b58 100644 --- a/.github/workflows/pr-nitpick-reviewer.lock.yml +++ b/.github/workflows/pr-nitpick-reviewer.lock.yml @@ -7741,10 +7741,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7757,2140 +7763,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "PR Nitpick Reviewer 🔍" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🔍 *Meticulously inspected by [{workflow_name}]({run_url})*\",\"runStarted\":\"🔬 Adjusting monocle... [{workflow_name}]({run_url}) is scrutinizing every pixel of this {event_type}...\",\"runSuccess\":\"🔍 Nitpicks catalogued! [{workflow_name}]({run_url}) has documented all the tiny details. Perfection awaits! ✅\",\"runFailure\":\"🔬 Lens cracked! [{workflow_name}]({run_url}) {status}. Some nitpicks remain undetected...\"}" + GH_AW_DISCUSSION_CATEGORY: "General" + GH_AW_DISCUSSION_TITLE_PREFIX: "[nitpick-report] " with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; - } - summaryContent += "---\n\n"; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); - return; - } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; - } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; - } - } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } - } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; - } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; - } - } - await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); - } - (async () => { await main(); })(); - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_CREATED_DISCUSSION_URL: ${{ steps.create_discussion.outputs.discussion_url }} - GH_AW_CREATED_DISCUSSION_NUMBER: ${{ steps.create_discussion.outputs.discussion_number }} - GH_AW_WORKFLOW_NAME: "PR Nitpick Reviewer 🔍" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🔍 *Meticulously inspected by [{workflow_name}]({run_url})*\",\"runStarted\":\"🔬 Adjusting monocle... [{workflow_name}]({run_url}) is scrutinizing every pixel of this {event_type}...\",\"runSuccess\":\"🔍 Nitpicks catalogued! [{workflow_name}]({run_url}) has documented all the tiny details. Perfection awaits! ✅\",\"runFailure\":\"🔬 Lens cracked! [{workflow_name}]({run_url}) {status}. Some nitpicks remain undetected...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } - } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } - async function main() { + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } - } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); - return; - } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; - } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } + summaryContent += "---\n\n"; } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; - } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); - } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); - } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); - } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); - } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; - } - (async () => { await main(); })(); - - name: Create PR Review Comment - id: create_pr_review_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request_review_comment')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PR_REVIEW_COMMENT_SIDE: "RIGHT" - GH_AW_WORKFLOW_NAME: "PR Nitpick Reviewer 🔍" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🔍 *Meticulously inspected by [{workflow_name}]({run_url})*\",\"runStarted\":\"🔬 Adjusting monocle... [{workflow_name}]({run_url}) is scrutinizing every pixel of this {event_type}...\",\"runSuccess\":\"🔍 Nitpicks catalogued! [{workflow_name}]({run_url}) has documented all the tiny details. Perfection awaits! ✅\",\"runFailure\":\"🔬 Lens cracked! [{workflow_name}]({run_url}) {status}. Some nitpicks remain undetected...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const reviewCommentItems = result.items.filter( item => item.type === "create_pull_request_review_comment"); - if (reviewCommentItems.length === 0) { - core.info("No create-pull-request-review-comment items found in agent output"); - return; - } - core.info(`Found ${reviewCommentItems.length} create-pull-request-review-comment item(s)`); - if (isStaged) { - await generateStagedPreview({ - title: "Create PR Review Comments", - description: "The following review comments would be created if staged mode was disabled:", - items: reviewCommentItems, - renderItem: (item, index) => { - let content = `#### Review Comment ${index + 1}\n`; - if (item.pull_request_number) { - const repoUrl = getRepositoryUrl(); - const pullUrl = `${repoUrl}/pull/${item.pull_request_number}`; - content += `**Target PR:** [#${item.pull_request_number}](${pullUrl})\n\n`; - } else { - content += `**Target:** Current PR\n\n`; - } - content += `**File:** ${item.path || "No path provided"}\n\n`; - content += `**Line:** ${item.line || "No line provided"}\n\n`; - if (item.start_line) { - content += `**Start Line:** ${item.start_line}\n\n`; - } - content += `**Side:** ${item.side || "RIGHT"}\n\n`; - content += `**Body:**\n${item.body || "No content provided"}\n\n`; - return content; - }, - }); + core.info("📝 Safe outputs preview written to step summary"); return; } - const defaultSide = process.env.GH_AW_PR_REVIEW_COMMENT_SIDE || "RIGHT"; - core.info(`Default comment side configuration: ${defaultSide}`); - const commentTarget = process.env.GH_AW_PR_REVIEW_COMMENT_TARGET || "triggering"; - core.info(`PR review comment target configuration: ${commentTarget}`); - const isPRContext = - context.eventName === "pull_request" || - context.eventName === "pull_request_review" || - context.eventName === "pull_request_review_comment" || - (context.eventName === "issue_comment" && context.payload.issue && context.payload.issue.pull_request); - if (commentTarget === "triggering" && !isPRContext) { - core.info('Target is "triggering" but not running in pull request context, skipping review comment creation'); - return; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < reviewCommentItems.length; i++) { - const commentItem = reviewCommentItems[i]; - core.info( - `Processing create-pull-request-review-comment item ${i + 1}/${reviewCommentItems.length}: bodyLength=${commentItem.body ? commentItem.body.length : "undefined"}, path=${commentItem.path}, line=${commentItem.line}, startLine=${commentItem.start_line}` - ); - if (!commentItem.path) { - core.info('Missing required field "path" in review comment item'); - continue; - } - if (!commentItem.line || (typeof commentItem.line !== "number" && typeof commentItem.line !== "string")) { - core.info('Missing or invalid required field "line" in review comment item'); - continue; - } - if (!commentItem.body || typeof commentItem.body !== "string") { - core.info('Missing or invalid required field "body" in review comment item'); - continue; - } - let pullRequestNumber; - let pullRequest; - if (commentTarget === "*") { - if (commentItem.pull_request_number) { - pullRequestNumber = parseInt(commentItem.pull_request_number, 10); - if (isNaN(pullRequestNumber) || pullRequestNumber <= 0) { - core.info(`Invalid pull request number specified: ${commentItem.pull_request_number}`); - continue; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - } else { - core.info('Target is "*" but no pull_request_number specified in comment item'); - continue; } - } else if (commentTarget && commentTarget !== "triggering") { - pullRequestNumber = parseInt(commentTarget, 10); - if (isNaN(pullRequestNumber) || pullRequestNumber <= 0) { - core.info(`Invalid pull request number in target configuration: ${commentTarget}`); - continue; - } - } else { - if (context.payload.pull_request) { - pullRequestNumber = context.payload.pull_request.number; - pullRequest = context.payload.pull_request; - } else if (context.payload.issue && context.payload.issue.pull_request) { - pullRequestNumber = context.payload.issue.number; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } - if (!pullRequestNumber) { - core.info("Could not determine pull request number"); - continue; } - if (!pullRequest || !pullRequest.head || !pullRequest.head.sha) { - try { - const { data: fullPR } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pullRequestNumber, - }); - pullRequest = fullPR; - core.info(`Fetched full pull request details for PR #${pullRequestNumber}`); - } catch (error) { - core.info(`Failed to fetch pull request details for PR #${pullRequestNumber}: ${error instanceof Error ? error.message : String(error)}`); - continue; - } - } - if (!pullRequest || !pullRequest.head || !pullRequest.head.sha) { - core.info(`Pull request head commit SHA not found for PR #${pullRequestNumber} - cannot create review comment`); - continue; - } - core.info(`Creating review comment on PR #${pullRequestNumber}`); - const line = parseInt(commentItem.line, 10); - if (isNaN(line) || line <= 0) { - core.info(`Invalid line number: ${commentItem.line}`); - continue; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } - let startLine = undefined; - if (commentItem.start_line) { - startLine = parseInt(commentItem.start_line, 10); - if (isNaN(startLine) || startLine <= 0 || startLine > line) { - core.info(`Invalid start_line number: ${commentItem.start_line} (must be <= line: ${line})`); - continue; + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } } - const side = commentItem.side || defaultSide; - if (side !== "LEFT" && side !== "RIGHT") { - core.info(`Invalid side value: ${side} (must be LEFT or RIGHT)`); - continue; - } - let body = commentItem.body.trim(); - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - body += generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - core.info(`Creating review comment on PR #${pullRequestNumber} at ${commentItem.path}:${line}${startLine ? ` (lines ${startLine}-${line})` : ""} [${side}]`); - core.info(`Comment content length: ${body.length}`); - try { - const requestParams = { - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pullRequestNumber, - body: body, - path: commentItem.path, - commit_id: pullRequest && pullRequest.head ? pullRequest.head.sha : "", - line: line, - side: side, - }; - if (startLine !== undefined) { - requestParams.start_line = startLine; - requestParams.start_side = side; - } - const { data: comment } = await github.rest.pulls.createReviewComment(requestParams); - core.info("Created review comment #" + comment.id + ": " + comment.html_url); - createdComments.push(comment); - if (i === reviewCommentItems.length - 1) { - core.setOutput("review_comment_id", comment.id); - core.setOutput("review_comment_url", comment.html_url); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } - } catch (error) { - core.error(`✗ Failed to create review comment: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } - } - if (createdComments.length > 0) { - let summaryContent = "\n\n## GitHub PR Review Comments\n"; - for (const comment of createdComments) { - summaryContent += `- Review Comment #${comment.id}: [View Comment](${comment.html_url})\n`; } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} review comment(s)`); - return createdComments; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); update_cache_memory: needs: diff --git a/.github/workflows/prompt-clustering-analysis.lock.yml b/.github/workflows/prompt-clustering-analysis.lock.yml index 3612450bc4..a427f53fba 100644 --- a/.github/workflows/prompt-clustering-analysis.lock.yml +++ b/.github/workflows/prompt-clustering-analysis.lock.yml @@ -7157,8 +7157,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7171,1173 +7179,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Copilot Agent Prompt Clustering Analysis" - GH_AW_ENGINE_ID: "claude" - with: + GH_AW_DISCUSSION_CATEGORY: "audits" + GH_AW_DISCUSSION_TITLE_PREFIX: "[prompt-clustering] " +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; - } - } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); } } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); update_cache_memory: needs: diff --git a/.github/workflows/python-data-charts.lock.yml b/.github/workflows/python-data-charts.lock.yml index b7ace5b4ca..aaed25b411 100644 --- a/.github/workflows/python-data-charts.lock.yml +++ b/.github/workflows/python-data-charts.lock.yml @@ -7638,8 +7638,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7652,1311 +7660,178 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Python Data Visualization Generator" - GH_AW_ENGINE_ID: "copilot" + GH_AW_DISCUSSION_CATEGORY: "artifacts" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; } } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); - - name: Upload Assets - id: upload_assets - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'upload_asset')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Python Data Visualization Generator" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const path = require("path"); - const crypto = require("crypto"); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function normalizeBranchName(branchName) { - if (!branchName || typeof branchName !== "string" || branchName.trim() === "") { - return branchName; - } - let normalized = branchName.replace(/[^a-zA-Z0-9\-_/.]+/g, "-"); - normalized = normalized.replace(/-+/g, "-"); - normalized = normalized.replace(/^-+|-+$/g, ""); - if (normalized.length > 128) { - normalized = normalized.substring(0, 128); - } - normalized = normalized.replace(/-+$/, ""); - normalized = normalized.toLowerCase(); - return normalized; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const branchName = process.env.GH_AW_ASSETS_BRANCH; - if (!branchName || typeof branchName !== "string") { - core.setFailed("GH_AW_ASSETS_BRANCH environment variable is required but not set"); - return; - } - const normalizedBranchName = normalizeBranchName(branchName); - core.info(`Using assets branch: ${normalizedBranchName}`); - const result = loadAgentOutput(); - if (!result.success) { - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - const uploadItems = result.items.filter( item => item.type === "upload_assets"); - const uploadAssetItems = result.items.filter( item => item.type === "upload_asset"); - const allUploadItems = [...uploadItems, ...uploadAssetItems]; - if (allUploadItems.length === 0) { - core.info("No upload-asset items found in agent output"); - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - core.info(`Found ${allUploadItems.length} upload-asset item(s)`); - let uploadCount = 0; - let hasChanges = false; + (async () => { try { - try { - await exec.exec(`git rev-parse --verify origin/${normalizedBranchName}`); - await exec.exec(`git checkout -B ${normalizedBranchName} origin/${normalizedBranchName}`); - core.info(`Checked out existing branch from origin: ${normalizedBranchName}`); - } catch (originError) { - if (!normalizedBranchName.startsWith("assets/")) { - core.setFailed( - `Branch '${normalizedBranchName}' does not start with the required 'assets/' prefix. ` + - `Orphaned branches can only be automatically created under the 'assets/' prefix. ` + - `Please create the branch manually first, or use a branch name starting with 'assets/'.` - ); - return; - } - core.info(`Creating new orphaned branch: ${normalizedBranchName}`); - await exec.exec(`git checkout --orphan ${normalizedBranchName}`); - await exec.exec(`git rm -rf .`); - await exec.exec(`git clean -fdx`); - } - for (const asset of uploadAssetItems) { - try { - const { fileName, sha, size, targetFileName } = asset; - if (!fileName || !sha || !targetFileName) { - core.error(`Invalid asset entry missing required fields: ${JSON.stringify(asset)}`); - continue; - } - const assetSourcePath = path.join("/tmp/gh-aw/safeoutputs/assets", fileName); - if (!fs.existsSync(assetSourcePath)) { - core.warning(`Asset file not found: ${assetSourcePath}`); - continue; - } - const fileContent = fs.readFileSync(assetSourcePath); - const computedSha = crypto.createHash("sha256").update(fileContent).digest("hex"); - if (computedSha !== sha) { - core.warning(`SHA mismatch for ${fileName}: expected ${sha}, got ${computedSha}`); - continue; - } - if (fs.existsSync(targetFileName)) { - core.info(`Asset ${targetFileName} already exists, skipping`); - continue; - } - fs.copyFileSync(assetSourcePath, targetFileName); - await exec.exec(`git add "${targetFileName}"`); - uploadCount++; - hasChanges = true; - core.info(`Added asset: ${targetFileName} (${size} bytes)`); - } catch (error) { - core.warning(`Failed to process asset ${asset.fileName}: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (hasChanges) { - const commitMessage = `[skip-ci] Add ${uploadCount} asset(s)`; - await exec.exec(`git`, [`commit`, `-m`, commitMessage]); - if (isStaged) { - core.summary.addRaw("## Staged Asset Publication"); - } else { - await exec.exec(`git push origin ${normalizedBranchName}`); - core.summary.addRaw("## Assets").addRaw(`Successfully uploaded **${uploadCount}** assets to branch \`${normalizedBranchName}\``).addRaw(""); - core.info(`Successfully uploaded ${uploadCount} assets to branch ${normalizedBranchName}`); - } - for (const asset of uploadAssetItems) { - if (asset.fileName && asset.sha && asset.size && asset.url) { - core.summary.addRaw(`- [\`${asset.fileName}\`](${asset.url}) → \`${asset.targetFileName}\` (${asset.size} bytes)`); - } - } - core.summary.write(); - } else { - core.info("No new assets to upload"); - } + await main(); } catch (error) { - core.setFailed(`Failed to upload assets: ${error instanceof Error ? error.message : String(error)}`); - return; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - core.setOutput("upload_count", uploadCount.toString()); - core.setOutput("branch_name", normalizedBranchName); - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/q.lock.yml b/.github/workflows/q.lock.yml index 5b00eb74ea..b965d64909 100644 --- a/.github/workflows/q.lock.yml +++ b/.github/workflows/q.lock.yml @@ -8028,10 +8028,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} - create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -8044,1766 +8050,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Create Pull Request - id: create_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PR_TITLE_PREFIX: "[q] " GH_AW_PR_LABELS: "automation,workflow-optimization" - GH_AW_PR_DRAFT: "false" - GH_AW_PR_IF_NO_CHANGES: "ignore" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Q" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🎩 *Equipped by [{workflow_name}]({run_url})*\",\"runStarted\":\"🔧 Pay attention, 007! [{workflow_name}]({run_url}) is preparing your gadgets for this {event_type}...\",\"runSuccess\":\"🎩 Mission equipment ready! [{workflow_name}]({run_url}) has optimized your workflow. Use wisely, 007! 🔫\",\"runFailure\":\"🔧 Technical difficulties! [{workflow_name}]({run_url}) {status}. Even Q Branch has bad days...\"}" - with: + GH_AW_PR_TITLE_PREFIX: "[q] " +GH_AW_PR_DRAFT=falseGH_AW_PR_IF_NO_CHANGES=ignore with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { - core.setOutput("pull_request_number", ""); - core.setOutput("pull_request_url", ""); core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); - core.setOutput("branch_name", ""); - core.setOutput("fallback_used", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const workflowId = process.env.GH_AW_WORKFLOW_ID; - if (!workflowId) { - throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); - } - const baseBranch = process.env.GH_AW_BASE_BRANCH; - if (!baseBranch) { - throw new Error("GH_AW_BASE_BRANCH environment variable is required"); - } - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - let outputContent = ""; - if (agentOutputFile.trim() !== "") { - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - } - const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn"; - const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - let patchContent = ""; - let isEmpty = true; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } - if (patchContent.includes("Failed to generate patch")) { - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return; - } - throw new Error(message); - } - core.info("Patch size validation passed"); - } - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to push - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); return; } - core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; - } - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); - return; - } - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - if (!title) { - title = "Agent Output"; - } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - const labelsEnv = process.env.GH_AW_PR_LABELS; - const labels = labelsEnv - ? labelsEnv - .split(",") - .map( label => label.trim()) - .filter( label => label) - : []; - const draftEnv = process.env.GH_AW_PR_DRAFT; - const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); - const randomHex = crypto.randomBytes(8).toString("hex"); - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; - } else { - branchName = `${branchName}-${randomHex}`; - core.info(`Using branch name from JSONL with added salt: ${branchName}`); - } - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); - core.info(`Fetching latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${baseBranch}`); - core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`); - await exec.exec(`git checkout -b ${branchName}`); - core.info(`Created new branch from base: ${branchName}`); - if (!isEmpty) { - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - try { - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - try { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Changes pushed to branch"); - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - > [!NOTE] - > This was originally intended as a pull request, but the git push operation failed. - > - > **Workflow Run:** [View run details and download patch artifact](${runUrl}) - > - > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above. - To apply the patch locally: - \`\`\`sh - # Download the artifact from the workflow run ${runUrl} - # (Use GitHub MCP tools if gh CLI is not available) - gh run download ${runId} -n aw.patch - # Apply the patch - git am aw.patch - \`\`\` - ${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - core.setOutput("push_failed", "true"); - await core.summary - .addRaw( - ` - ## Push Failure Fallback - - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} - - **Fallback Issue:** [#${issue.number}](${issue.html_url}) - - **Patch Artifact:** Available in workflow run artifacts - - **Note:** Push failed, created issue as fallback - ` - ) - .write(); - return; - } catch (issueError) { - core.setFailed( - `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` - ); - return; - } - } - } else { - core.info("Skipping patch application (empty patch)"); - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - try { - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; } - await exec.exec(`git push origin ${branchName}`); - core.info("Empty branch pushed successfully"); - } catch (pushError) { - core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - return; - } - } else { - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to apply - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - try { - const { data: pullRequest } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }); - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - labels: labels, - }); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } - core.setOutput("pull_request_number", pullRequest.number); - core.setOutput("pull_request_url", pullRequest.html_url); - core.setOutput("branch_name", branchName); - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - await core.summary - .addRaw( - ` - ## Pull Request - - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - - **Branch**: \`${branchName}\` - - **Base Branch**: \`${baseBranch}\` - ` - ) - .write(); - } catch (prError) { - core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); - core.info("Falling back to creating an issue instead"); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). - **Original error:** ${prError instanceof Error ? prError.message : String(prError)} - You can manually create a pull request from the branch if needed.${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - await core.summary - .addRaw( - ` - ## Fallback Issue Created - - **Issue**: [#${issue.number}](${issue.html_url}) - - **Branch**: [\`${branchName}\`](${branchUrl}) - - **Base Branch**: \`${baseBranch}\` - - **Note**: Pull request creation failed, created issue as fallback - ` - ) - .write(); - } catch (issueError) { - core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`); - return; - } - } - } - (async () => { await main(); })(); - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_CREATED_PULL_REQUEST_URL: ${{ steps.create_pull_request.outputs.pull_request_url }} - GH_AW_CREATED_PULL_REQUEST_NUMBER: ${{ steps.create_pull_request.outputs.pull_request_number }} - GH_AW_WORKFLOW_NAME: "Q" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🎩 *Equipped by [{workflow_name}]({run_url})*\",\"runStarted\":\"🔧 Pay attention, 007! [{workflow_name}]({run_url}) is preparing your gadgets for this {event_type}...\",\"runSuccess\":\"🎩 Mission equipment ready! [{workflow_name}]({run_url}) has optimized your workflow. Use wisely, 007! 🔫\",\"runFailure\":\"🔧 Technical difficulties! [{workflow_name}]({run_url}) {status}. Even Q Branch has bad days...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } + summaryContent += "---\n\n"; } } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } - } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; - } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); - return; } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; - } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; - } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); - } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); - } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); update_cache_memory: needs: diff --git a/.github/workflows/release.lock.yml b/.github/workflows/release.lock.yml index 409e0dceee..3fb11f54c9 100644 --- a/.github/workflows/release.lock.yml +++ b/.github/workflows/release.lock.yml @@ -6861,6 +6861,17 @@ jobs: permissions: contents: write timeout-minutes: 15 + outputs: + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6873,282 +6884,175 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - - name: Update Release - id: update_release - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'update_release')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Release" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const updateItems = result.items.filter( item => item.type === "update_release"); - if (updateItems.length === 0) { - core.info("No update-release items found in agent output"); - return; - } - core.info(`Found ${updateItems.length} update-release item(s)`); + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Update Releases", - description: "The following release updates would be applied if staged mode was disabled:", - items: updateItems, - renderItem: (item, index) => { - let content = `#### Release Update ${index + 1}\n`; - content += `**Tag:** ${item.tag || "(inferred from event context)"}\n`; - content += `**Operation:** ${item.operation}\n\n`; - content += `**Body Content:**\n${item.body}\n\n`; - return content; - }, - }); + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; + } + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "GitHub Agentic Workflow"; - const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - const updatedReleases = []; - for (let i = 0; i < updateItems.length; i++) { - const updateItem = updateItems[i]; - core.info(`Processing update-release item ${i + 1}/${updateItems.length}`); - try { - let releaseTag = updateItem.tag; - if (!releaseTag) { - if (context.eventName === "release" && context.payload.release && context.payload.release.tag_name) { - releaseTag = context.payload.release.tag_name; - core.info(`Inferred release tag from event context: ${releaseTag}`); - } else if (context.eventName === "workflow_dispatch" && context.payload.inputs) { - const releaseUrl = context.payload.inputs.release_url; - if (releaseUrl) { - const urlMatch = releaseUrl.match(/github\.com\/[^\/]+\/[^\/]+\/releases\/tag\/([^\/\?#]+)/); - if (urlMatch && urlMatch[1]) { - releaseTag = decodeURIComponent(urlMatch[1]); - core.info(`Inferred release tag from release_url input: ${releaseTag}`); - } - } - if (!releaseTag && context.payload.inputs.release_id) { - const releaseId = context.payload.inputs.release_id; - core.info(`Fetching release with ID: ${releaseId}`); - const { data: release } = await github.rest.repos.getRelease({ - owner: context.repo.owner, - repo: context.repo.repo, - release_id: parseInt(releaseId, 10), - }); - releaseTag = release.tag_name; - core.info(`Inferred release tag from release_id input: ${releaseTag}`); - } - } - if (!releaseTag) { - core.error("No tag provided and unable to infer from event context"); - core.setFailed("Release tag is required but not provided and cannot be inferred from event context"); - return; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } - core.info(`Fetching release with tag: ${releaseTag}`); - const { data: release } = await github.rest.repos.getReleaseByTag({ - owner: context.repo.owner, - repo: context.repo.repo, - tag: releaseTag, - }); - core.info(`Found release: ${release.name || release.tag_name} (ID: ${release.id})`); - let newBody; - if (updateItem.operation === "replace") { - newBody = updateItem.body; - core.info("Operation: replace (full body replacement)"); - } else if (updateItem.operation === "prepend") { - const aiFooter = `\n\n> AI generated by [${workflowName}](${runUrl})`; - const prependSection = `${updateItem.body}${aiFooter}\n\n---\n\n`; - newBody = prependSection + (release.body || ""); - core.info("Operation: prepend (add to start with separator)"); - } else { - const aiFooter = `\n\n> AI generated by [${workflowName}](${runUrl})`; - const appendSection = `\n\n---\n\n${updateItem.body}${aiFooter}`; - newBody = (release.body || "") + appendSection; - core.info("Operation: append (add to end with separator)"); - } - const { data: updatedRelease } = await github.rest.repos.updateRelease({ - owner: context.repo.owner, - repo: context.repo.repo, - release_id: release.id, - body: newBody, - }); - core.info(`Successfully updated release: ${updatedRelease.html_url}`); - updatedReleases.push({ - tag: releaseTag, - url: updatedRelease.html_url, - id: updatedRelease.id, - }); - if (i === 0) { - core.setOutput("release_id", updatedRelease.id); - core.setOutput("release_url", updatedRelease.html_url); - core.setOutput("release_tag", updatedRelease.tag_name); + } + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - const tagInfo = updateItem.tag || "inferred from context"; - core.error(`Failed to update release with tag ${tagInfo}: ${errorMessage}`); - if (errorMessage.includes("Not Found")) { - core.error(`Release with tag '${tagInfo}' not found. Please ensure the tag exists.`); + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } - core.setFailed(`Failed to update release: ${errorMessage}`); - return; } + await core.summary.addRaw(summaryContent).write(); } - let summaryContent = `## ✅ Release Updates Complete\n\n`; - summaryContent += `Updated ${updatedReleases.length} release(s):\n\n`; - for (const rel of updatedReleases) { - summaryContent += `- **${rel.tag}**: [View Release](${rel.url})\n`; - } - await core.summary.addRaw(summaryContent).write(); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); diff --git a/.github/workflows/repo-tree-map.lock.yml b/.github/workflows/repo-tree-map.lock.yml index 14ec279840..68cb46b659 100644 --- a/.github/workflows/repo-tree-map.lock.yml +++ b/.github/workflows/repo-tree-map.lock.yml @@ -6635,8 +6635,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6649,1171 +6657,176 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Repository Tree Map Generator" - GH_AW_ENGINE_ID: "copilot" - with: + GH_AW_DISCUSSION_CATEGORY: "dev" +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; } } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); diff --git a/.github/workflows/repository-quality-improver.lock.yml b/.github/workflows/repository-quality-improver.lock.yml index 1a11d45281..bdb64fefd0 100644 --- a/.github/workflows/repository-quality-improver.lock.yml +++ b/.github/workflows/repository-quality-improver.lock.yml @@ -7193,8 +7193,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7207,1173 +7215,178 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Repository Quality Improvement Agent" - GH_AW_ENGINE_ID: "copilot" - with: + GH_AW_DISCUSSION_CATEGORY: "general" +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; - } - } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); } } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); update_cache_memory: needs: diff --git a/.github/workflows/research.lock.yml b/.github/workflows/research.lock.yml index 19f7dfe3e0..d1e01c43d7 100644 --- a/.github/workflows/research.lock.yml +++ b/.github/workflows/research.lock.yml @@ -6603,8 +6603,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6617,1171 +6625,176 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Basic Research Agent" - GH_AW_ENGINE_ID: "copilot" + GH_AW_DISCUSSION_CATEGORY: "research" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; } } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); diff --git a/.github/workflows/safe-output-health.lock.yml b/.github/workflows/safe-output-health.lock.yml index a2293725a9..7b59a15387 100644 --- a/.github/workflows/safe-output-health.lock.yml +++ b/.github/workflows/safe-output-health.lock.yml @@ -6691,8 +6691,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6705,1173 +6713,178 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Safe Output Health Monitor" - GH_AW_ENGINE_ID: "claude" - with: + GH_AW_DISCUSSION_CATEGORY: "audits" +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; - } - } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); } } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); update_cache_memory: needs: diff --git a/.github/workflows/schema-consistency-checker.lock.yml b/.github/workflows/schema-consistency-checker.lock.yml index 1a34f2f30a..099031d099 100644 --- a/.github/workflows/schema-consistency-checker.lock.yml +++ b/.github/workflows/schema-consistency-checker.lock.yml @@ -6458,8 +6458,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6472,1173 +6480,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Schema Consistency Checker" - GH_AW_ENGINE_ID: "claude" - with: + GH_AW_DISCUSSION_CATEGORY: "audits" + GH_AW_DISCUSSION_TITLE_PREFIX: "[Schema Consistency] " +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; } } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); update_cache_memory: needs: diff --git a/.github/workflows/scout.lock.yml b/.github/workflows/scout.lock.yml index 5ab282cfc3..abaf5ab436 100644 --- a/.github/workflows/scout.lock.yml +++ b/.github/workflows/scout.lock.yml @@ -7537,8 +7537,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7551,1021 +7559,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Scout" - GH_AW_ENGINE_ID: "claude" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🔭 *Intelligence gathered by [{workflow_name}]({run_url})*\",\"runStarted\":\"🏕️ Scout on patrol! [{workflow_name}]({run_url}) is blazing trails through this {event_type}...\",\"runSuccess\":\"🔭 Recon complete! [{workflow_name}]({run_url}) has charted the territory. Map ready! 🗺️\",\"runFailure\":\"🏕️ Lost in the wilderness! [{workflow_name}]({run_url}) {status}. Sending search party...\"}" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } - } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; - } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); - } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); update_cache_memory: needs: diff --git a/.github/workflows/security-compliance.lock.yml b/.github/workflows/security-compliance.lock.yml index 8e0f47e9bd..dd845422a0 100644 --- a/.github/workflows/security-compliance.lock.yml +++ b/.github/workflows/security-compliance.lock.yml @@ -7018,9 +7018,16 @@ jobs: issues: write timeout-minutes: 15 outputs: - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7033,943 +7040,176 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_ISSUE_LABELS: "security,campaign-tracker" - GH_AW_WORKFLOW_NAME: "Security Compliance Campaign" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; + summaryContent += `**Repository:** ${item.repo}\n\n`; } if (item.body) { - content += `**Body:**\n${item.body}\n\n`; + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; - } - return content; - }, - }); + summaryContent += "---\n\n"; + } + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } - } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } - } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } await core.summary.addRaw(summaryContent).write(); } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } (async () => { - await main(); + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } })(); diff --git a/.github/workflows/security-fix-pr.lock.yml b/.github/workflows/security-fix-pr.lock.yml index a66171a80f..dc9479b2c3 100644 --- a/.github/workflows/security-fix-pr.lock.yml +++ b/.github/workflows/security-fix-pr.lock.yml @@ -6488,8 +6488,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6502,774 +6510,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Create Pull Request - id: create_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PR_TITLE_PREFIX: "[security-fix] " GH_AW_PR_LABELS: "security,automated-fix" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Security Fix PR" - GH_AW_ENGINE_ID: "claude" + GH_AW_PR_TITLE_PREFIX: "[security-fix] " with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { - core.setOutput("pull_request_number", ""); - core.setOutput("pull_request_url", ""); core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); - core.setOutput("branch_name", ""); - core.setOutput("fallback_used", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const workflowId = process.env.GH_AW_WORKFLOW_ID; - if (!workflowId) { - throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); - } - const baseBranch = process.env.GH_AW_BASE_BRANCH; - if (!baseBranch) { - throw new Error("GH_AW_BASE_BRANCH environment variable is required"); - } - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - let outputContent = ""; - if (agentOutputFile.trim() !== "") { - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - } - const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn"; - const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - let patchContent = ""; - let isEmpty = true; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } - if (patchContent.includes("Failed to generate patch")) { - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return; - } - throw new Error(message); - } - core.info("Patch size validation passed"); - } - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to push - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); return; } - core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - if (!title) { - title = "Agent Output"; - } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - const labelsEnv = process.env.GH_AW_PR_LABELS; - const labels = labelsEnv - ? labelsEnv - .split(",") - .map( label => label.trim()) - .filter( label => label) - : []; - const draftEnv = process.env.GH_AW_PR_DRAFT; - const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); - const randomHex = crypto.randomBytes(8).toString("hex"); - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); } else { - branchName = `${branchName}-${randomHex}`; - core.info(`Using branch name from JSONL with added salt: ${branchName}`); - } - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); - core.info(`Fetching latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${baseBranch}`); - core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`); - await exec.exec(`git checkout -b ${branchName}`); - core.info(`Created new branch from base: ${branchName}`); - if (!isEmpty) { - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - try { - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - try { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Changes pushed to branch"); - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - > [!NOTE] - > This was originally intended as a pull request, but the git push operation failed. - > - > **Workflow Run:** [View run details and download patch artifact](${runUrl}) - > - > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above. - To apply the patch locally: - \`\`\`sh - # Download the artifact from the workflow run ${runUrl} - # (Use GitHub MCP tools if gh CLI is not available) - gh run download ${runId} -n aw.patch - # Apply the patch - git am aw.patch - \`\`\` - ${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - core.setOutput("push_failed", "true"); - await core.summary - .addRaw( - ` - ## Push Failure Fallback - - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} - - **Fallback Issue:** [#${issue.number}](${issue.html_url}) - - **Patch Artifact:** Available in workflow run artifacts - - **Note:** Push failed, created issue as fallback - ` - ) - .write(); - return; - } catch (issueError) { - core.setFailed( - `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` - ); - return; } } - } else { - core.info("Skipping patch application (empty patch)"); - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - try { - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Empty branch pushed successfully"); - } catch (pushError) { - core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - return; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - } else { - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to apply - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } + await core.summary.addRaw(summaryContent).write(); } + core.info(`Completed processing ${result.items.length} safe output item(s)`); + } + (async () => { try { - const { data: pullRequest } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }); - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - labels: labels, - }); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } - core.setOutput("pull_request_number", pullRequest.number); - core.setOutput("pull_request_url", pullRequest.html_url); - core.setOutput("branch_name", branchName); - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - await core.summary - .addRaw( - ` - ## Pull Request - - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - - **Branch**: \`${branchName}\` - - **Base Branch**: \`${baseBranch}\` - ` - ) - .write(); - } catch (prError) { - core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); - core.info("Falling back to creating an issue instead"); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). - **Original error:** ${prError instanceof Error ? prError.message : String(prError)} - You can manually create a pull request from the branch if needed.${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - await core.summary - .addRaw( - ` - ## Fallback Issue Created - - **Issue**: [#${issue.number}](${issue.html_url}) - - **Branch**: [\`${branchName}\`](${branchUrl}) - - **Base Branch**: \`${baseBranch}\` - - **Note**: Pull request creation failed, created issue as fallback - ` - ) - .write(); - } catch (issueError) { - core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`); - return; - } + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/semantic-function-refactor.lock.yml b/.github/workflows/semantic-function-refactor.lock.yml index 3e5b3bfa9f..94f82e73e3 100644 --- a/.github/workflows/semantic-function-refactor.lock.yml +++ b/.github/workflows/semantic-function-refactor.lock.yml @@ -6705,9 +6705,16 @@ jobs: issues: write timeout-minutes: 15 outputs: - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6720,1428 +6727,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_entity_helpers.cjs << 'EOF_96ffce00' - // @ts-check - /// - - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - - /** - * @typedef {'issue' | 'pull_request'} EntityType - */ - - /** - * @typedef {Object} EntityConfig - * @property {EntityType} entityType - The type of entity (issue or pull_request) - * @property {string} itemType - The agent output item type (e.g., "close_issue") - * @property {string} itemTypeDisplay - Human-readable item type for log messages (e.g., "close-issue") - * @property {string} numberField - The field name for the entity number in agent output (e.g., "issue_number") - * @property {string} envVarPrefix - Environment variable prefix (e.g., "GH_AW_CLOSE_ISSUE") - * @property {string[]} contextEvents - GitHub event names for this entity context - * @property {string} contextPayloadField - The field name in context.payload (e.g., "issue") - * @property {string} urlPath - URL path segment (e.g., "issues" or "pull") - * @property {string} displayName - Human-readable display name (e.g., "issue" or "pull request") - * @property {string} displayNamePlural - Human-readable display name plural (e.g., "issues" or "pull requests") - * @property {string} displayNameCapitalized - Capitalized display name (e.g., "Issue" or "Pull Request") - * @property {string} displayNameCapitalizedPlural - Capitalized display name plural (e.g., "Issues" or "Pull Requests") - */ - - /** - * @typedef {Object} EntityCallbacks - * @property {(github: any, owner: string, repo: string, entityNumber: number) => Promise<{number: number, title: string, labels: Array<{name: string}>, html_url: string, state: string}>} getDetails - * @property {(github: any, owner: string, repo: string, entityNumber: number, message: string) => Promise<{id: number, html_url: string}>} addComment - * @property {(github: any, owner: string, repo: string, entityNumber: number) => Promise<{number: number, html_url: string, title: string}>} closeEntity - */ - - /** - * Build the run URL for the current workflow - * @returns {string} The workflow run URL - */ - function buildRunUrl() { - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - } - - /** - * Build comment body with tracker ID and footer - * @param {string} body - The original comment body - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - PR number that triggered this workflow - * @returns {string} The complete comment body with tracker ID and footer - */ - function buildCommentBody(body, triggeringIssueNumber, triggeringPRNumber) { - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runUrl = buildRunUrl(); - - let commentBody = body.trim(); - commentBody += getTrackerID("markdown"); - commentBody += generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, undefined); - - return commentBody; - } - - /** - * Check if labels match the required labels filter - * @param {Array<{name: string}>} entityLabels - Labels on the entity - * @param {string[]} requiredLabels - Required labels (any match) - * @returns {boolean} True if entity has at least one required label - */ - function checkLabelFilter(entityLabels, requiredLabels) { - if (requiredLabels.length === 0) { - return true; - } - const labelNames = entityLabels.map(l => l.name); - return requiredLabels.some(required => labelNames.includes(required)); - } - - /** - * Check if title matches the required prefix filter - * @param {string} title - Entity title - * @param {string} requiredTitlePrefix - Required title prefix - * @returns {boolean} True if title starts with required prefix - */ - function checkTitlePrefixFilter(title, requiredTitlePrefix) { - if (!requiredTitlePrefix) { - return true; - } - return title.startsWith(requiredTitlePrefix); - } - - /** - * Generate staged preview content for a close entity operation - * @param {EntityConfig} config - Entity configuration - * @param {any[]} items - Items to preview - * @param {string[]} requiredLabels - Required labels filter - * @param {string} requiredTitlePrefix - Required title prefix filter - * @returns {Promise} - */ - async function generateCloseEntityStagedPreview(config, items, requiredLabels, requiredTitlePrefix) { - let summaryContent = `## 🎭 Staged Mode: Close ${config.displayNameCapitalizedPlural} Preview\n\n`; - summaryContent += `The following ${config.displayNamePlural} would be closed if staged mode was disabled:\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += `### ${config.displayNameCapitalized} ${i + 1}\n`; - - const entityNumber = item[config.numberField]; - if (entityNumber) { - const repoUrl = getRepositoryUrl(); - const entityUrl = `${repoUrl}/${config.urlPath}/${entityNumber}`; - summaryContent += `**Target ${config.displayNameCapitalized}:** [#${entityNumber}](${entityUrl})\n\n`; - } else { - summaryContent += `**Target:** Current ${config.displayName}\n\n`; - } - - summaryContent += `**Comment:**\n${item.body || "No content provided"}\n\n`; - - if (requiredLabels.length > 0) { - summaryContent += `**Required Labels:** ${requiredLabels.join(", ")}\n\n`; - } - if (requiredTitlePrefix) { - summaryContent += `**Required Title Prefix:** ${requiredTitlePrefix}\n\n`; - } - - summaryContent += "---\n\n"; - } - - // Write to step summary - await core.summary.addRaw(summaryContent).write(); - core.info(`📝 ${config.displayNameCapitalized} close preview written to step summary`); - } - - /** - * Parse configuration from environment variables - * @param {string} envVarPrefix - Environment variable prefix - * @returns {{requiredLabels: string[], requiredTitlePrefix: string, target: string}} - */ - function parseEntityConfig(envVarPrefix) { - const labelsEnvVar = `${envVarPrefix}_REQUIRED_LABELS`; - const titlePrefixEnvVar = `${envVarPrefix}_REQUIRED_TITLE_PREFIX`; - const targetEnvVar = `${envVarPrefix}_TARGET`; - - const requiredLabels = process.env[labelsEnvVar] ? process.env[labelsEnvVar].split(",").map(l => l.trim()) : []; - const requiredTitlePrefix = process.env[titlePrefixEnvVar] || ""; - const target = process.env[targetEnvVar] || "triggering"; - - return { requiredLabels, requiredTitlePrefix, target }; - } - - /** - * Resolve the entity number based on target configuration and context - * @param {EntityConfig} config - Entity configuration - * @param {string} target - Target configuration ("triggering", "*", or explicit number) - * @param {any} item - The agent output item - * @param {boolean} isEntityContext - Whether we're in the correct entity context - * @returns {{success: true, number: number} | {success: false, message: string}} - */ - function resolveEntityNumber(config, target, item, isEntityContext) { - if (target === "*") { - const targetNumber = item[config.numberField]; - if (targetNumber) { - const parsed = parseInt(targetNumber, 10); - if (isNaN(parsed) || parsed <= 0) { - return { - success: false, - message: `Invalid ${config.displayName} number specified: ${targetNumber}`, - }; - } - return { success: true, number: parsed }; - } - return { - success: false, - message: `Target is "*" but no ${config.numberField} specified in ${config.itemTypeDisplay} item`, - }; - } - - if (target !== "triggering") { - const parsed = parseInt(target, 10); - if (isNaN(parsed) || parsed <= 0) { - return { - success: false, - message: `Invalid ${config.displayName} number in target configuration: ${target}`, - }; - } - return { success: true, number: parsed }; - } - - // Default behavior: use triggering entity - if (isEntityContext) { - const number = context.payload[config.contextPayloadField]?.number; - if (!number) { - return { - success: false, - message: `${config.displayNameCapitalized} context detected but no ${config.displayName} found in payload`, - }; - } - return { success: true, number }; - } - - return { - success: false, - message: `Not in ${config.displayName} context and no explicit target specified`, - }; - } - - /** - * Escape special markdown characters in a title - * @param {string} title - The title to escape - * @returns {string} Escaped title - */ - function escapeMarkdownTitle(title) { - return title.replace(/[[\]()]/g, "\\$&"); - } - - /** - * Process close entity items from agent output - * @param {EntityConfig} config - Entity configuration - * @param {EntityCallbacks} callbacks - Entity-specific API callbacks - * @returns {Promise|undefined>} - */ - async function processCloseEntityItems(config, callbacks) { - // Check if we're in staged mode - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - - const result = loadAgentOutput(); - if (!result.success) { - return; - } - - // Find all items of this type - const items = result.items.filter(/** @param {any} item */ item => item.type === config.itemType); - if (items.length === 0) { - core.info(`No ${config.itemTypeDisplay} items found in agent output`); - return; - } - - core.info(`Found ${items.length} ${config.itemTypeDisplay} item(s)`); - - // Get configuration from environment - const { requiredLabels, requiredTitlePrefix, target } = parseEntityConfig(config.envVarPrefix); - - core.info(`Configuration: requiredLabels=${requiredLabels.join(",")}, requiredTitlePrefix=${requiredTitlePrefix}, target=${target}`); - - // Check if we're in the correct entity context - const isEntityContext = config.contextEvents.some(event => context.eventName === event); - - // If in staged mode, emit step summary instead of closing entities - if (isStaged) { - await generateCloseEntityStagedPreview(config, items, requiredLabels, requiredTitlePrefix); - return; - } - - // Validate context based on target configuration - if (target === "triggering" && !isEntityContext) { - core.info(`Target is "triggering" but not running in ${config.displayName} context, skipping ${config.displayName} close`); - return; - } - - // Extract triggering context for footer generation - const triggeringIssueNumber = context.payload?.issue?.number; - const triggeringPRNumber = context.payload?.pull_request?.number; - - const closedEntities = []; - - // Process each item - for (let i = 0; i < items.length; i++) { - const item = items[i]; - core.info(`Processing ${config.itemTypeDisplay} item ${i + 1}/${items.length}: bodyLength=${item.body.length}`); - - // Resolve entity number - const resolved = resolveEntityNumber(config, target, item, isEntityContext); - if (!resolved.success) { - core.info(resolved.message); - continue; - } - const entityNumber = resolved.number; - - try { - // Fetch entity details to check filters - const entity = await callbacks.getDetails(github, context.repo.owner, context.repo.repo, entityNumber); - - // Apply label filter - if (!checkLabelFilter(entity.labels, requiredLabels)) { - core.info(`${config.displayNameCapitalized} #${entityNumber} does not have required labels: ${requiredLabels.join(", ")}`); - continue; - } - - // Apply title prefix filter - if (!checkTitlePrefixFilter(entity.title, requiredTitlePrefix)) { - core.info(`${config.displayNameCapitalized} #${entityNumber} does not have required title prefix: ${requiredTitlePrefix}`); - continue; - } - - // Check if already closed - if (entity.state === "closed") { - core.info(`${config.displayNameCapitalized} #${entityNumber} is already closed, skipping`); - continue; - } - - // Build comment body - const commentBody = buildCommentBody(item.body, triggeringIssueNumber, triggeringPRNumber); - - // Add comment before closing - const comment = await callbacks.addComment(github, context.repo.owner, context.repo.repo, entityNumber, commentBody); - core.info(`✓ Added comment to ${config.displayName} #${entityNumber}: ${comment.html_url}`); - - // Close the entity - const closedEntity = await callbacks.closeEntity(github, context.repo.owner, context.repo.repo, entityNumber); - core.info(`✓ Closed ${config.displayName} #${entityNumber}: ${closedEntity.html_url}`); - - closedEntities.push({ - entity: closedEntity, - comment, - }); - - // Set outputs for the last closed entity (for backward compatibility) - if (i === items.length - 1) { - const numberOutputName = config.entityType === "issue" ? "issue_number" : "pull_request_number"; - const urlOutputName = config.entityType === "issue" ? "issue_url" : "pull_request_url"; - core.setOutput(numberOutputName, closedEntity.number); - core.setOutput(urlOutputName, closedEntity.html_url); - core.setOutput("comment_url", comment.html_url); - } - } catch (error) { - core.error(`✗ Failed to close ${config.displayName} #${entityNumber}: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } - } - - // Write summary for all closed entities - if (closedEntities.length > 0) { - let summaryContent = `\n\n## Closed ${config.displayNameCapitalizedPlural}\n`; - for (const { entity, comment } of closedEntities) { - const escapedTitle = escapeMarkdownTitle(entity.title); - summaryContent += `- ${config.displayNameCapitalized} #${entity.number}: [${escapedTitle}](${entity.html_url}) ([comment](${comment.html_url}))\n`; - } - await core.summary.addRaw(summaryContent).write(); - } - - core.info(`Successfully closed ${closedEntities.length} ${config.displayName}(s)`); - return closedEntities; - } - - /** - * Configuration for closing issues - * @type {EntityConfig} - */ - const ISSUE_CONFIG = { - entityType: "issue", - itemType: "close_issue", - itemTypeDisplay: "close-issue", - numberField: "issue_number", - envVarPrefix: "GH_AW_CLOSE_ISSUE", - contextEvents: ["issues", "issue_comment"], - contextPayloadField: "issue", - urlPath: "issues", - displayName: "issue", - displayNamePlural: "issues", - displayNameCapitalized: "Issue", - displayNameCapitalizedPlural: "Issues", - }; - - /** - * Configuration for closing pull requests - * @type {EntityConfig} - */ - const PULL_REQUEST_CONFIG = { - entityType: "pull_request", - itemType: "close_pull_request", - itemTypeDisplay: "close-pull-request", - numberField: "pull_request_number", - envVarPrefix: "GH_AW_CLOSE_PR", - contextEvents: ["pull_request", "pull_request_review_comment"], - contextPayloadField: "pull_request", - urlPath: "pull", - displayName: "pull request", - displayNamePlural: "pull requests", - displayNameCapitalized: "Pull Request", - displayNameCapitalizedPlural: "Pull Requests", - }; - - module.exports = { - processCloseEntityItems, - generateCloseEntityStagedPreview, - checkLabelFilter, - checkTitlePrefixFilter, - parseEntityConfig, - resolveEntityNumber, - buildCommentBody, - escapeMarkdownTitle, - ISSUE_CONFIG, - PULL_REQUEST_CONFIG, - }; - - EOF_96ffce00 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_ISSUE_TITLE_PREFIX: "[refactor] " GH_AW_ISSUE_LABELS: "refactoring,code-quality,automated-analysis" - GH_AW_WORKFLOW_NAME: "Semantic Function Refactoring" - GH_AW_ENGINE_ID: "claude" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; + summaryContent += `**Repository:** ${item.repo}\n\n`; } if (item.body) { - content += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } - return content; - }, - }); + summaryContent += "---\n\n"; + } + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } - } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; - } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; } await core.summary.addRaw(summaryContent).write(); } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } (async () => { - await main(); - })(); - - name: Close Issue - id: close_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'close_issue')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Semantic Function Refactoring" - GH_AW_ENGINE_ID: "claude" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { processCloseEntityItems, ISSUE_CONFIG } = require('/tmp/gh-aw/scripts/close_entity_helpers.cjs'); - async function getIssueDetails(github, owner, repo, issueNumber) { - const { data: issue } = await github.rest.issues.get({ - owner, - repo, - issue_number: issueNumber, - }); - if (!issue) { - throw new Error(`Issue #${issueNumber} not found in ${owner}/${repo}`); + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - return issue; - } - async function addIssueComment(github, owner, repo, issueNumber, message) { - const { data: comment } = await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body: message, - }); - return comment; - } - async function closeIssue(github, owner, repo, issueNumber) { - const { data: issue } = await github.rest.issues.update({ - owner, - repo, - issue_number: issueNumber, - state: "closed", - }); - return issue; - } - async function main() { - return processCloseEntityItems(ISSUE_CONFIG, { - getDetails: getIssueDetails, - addComment: addIssueComment, - closeEntity: closeIssue, - }); - } - (async () => { await main(); })(); + })(); diff --git a/.github/workflows/slide-deck-maintainer.lock.yml b/.github/workflows/slide-deck-maintainer.lock.yml index 905b5d5bc2..4f6f07d9aa 100644 --- a/.github/workflows/slide-deck-maintainer.lock.yml +++ b/.github/workflows/slide-deck-maintainer.lock.yml @@ -7040,8 +7040,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7054,774 +7062,178 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Create Pull Request - id: create_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_PR_TITLE_PREFIX: "[slides] " - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Slide Deck Maintainer" - GH_AW_TRACKER_ID: "slide-deck-maintainer" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { - core.setOutput("pull_request_number", ""); - core.setOutput("pull_request_url", ""); core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); - core.setOutput("branch_name", ""); - core.setOutput("fallback_used", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const workflowId = process.env.GH_AW_WORKFLOW_ID; - if (!workflowId) { - throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); - } - const baseBranch = process.env.GH_AW_BASE_BRANCH; - if (!baseBranch) { - throw new Error("GH_AW_BASE_BRANCH environment variable is required"); - } - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - let outputContent = ""; - if (agentOutputFile.trim() !== "") { - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - } - const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn"; - const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - let patchContent = ""; - let isEmpty = true; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } - if (patchContent.includes("Failed to generate patch")) { - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return; - } - throw new Error(message); - } - core.info("Patch size validation passed"); - } - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to push - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); return; } - core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - if (!title) { - title = "Agent Output"; - } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - const labelsEnv = process.env.GH_AW_PR_LABELS; - const labels = labelsEnv - ? labelsEnv - .split(",") - .map( label => label.trim()) - .filter( label => label) - : []; - const draftEnv = process.env.GH_AW_PR_DRAFT; - const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); - const randomHex = crypto.randomBytes(8).toString("hex"); - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); } else { - branchName = `${branchName}-${randomHex}`; - core.info(`Using branch name from JSONL with added salt: ${branchName}`); - } - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); - core.info(`Fetching latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${baseBranch}`); - core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`); - await exec.exec(`git checkout -b ${branchName}`); - core.info(`Created new branch from base: ${branchName}`); - if (!isEmpty) { - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - try { - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - try { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Changes pushed to branch"); - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - > [!NOTE] - > This was originally intended as a pull request, but the git push operation failed. - > - > **Workflow Run:** [View run details and download patch artifact](${runUrl}) - > - > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above. - To apply the patch locally: - \`\`\`sh - # Download the artifact from the workflow run ${runUrl} - # (Use GitHub MCP tools if gh CLI is not available) - gh run download ${runId} -n aw.patch - # Apply the patch - git am aw.patch - \`\`\` - ${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - core.setOutput("push_failed", "true"); - await core.summary - .addRaw( - ` - ## Push Failure Fallback - - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} - - **Fallback Issue:** [#${issue.number}](${issue.html_url}) - - **Patch Artifact:** Available in workflow run artifacts - - **Note:** Push failed, created issue as fallback - ` - ) - .write(); - return; - } catch (issueError) { - core.setFailed( - `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` - ); - return; } } - } else { - core.info("Skipping patch application (empty patch)"); - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - try { - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Empty branch pushed successfully"); - } catch (pushError) { - core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - return; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - } else { - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to apply - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } + await core.summary.addRaw(summaryContent).write(); } + core.info(`Completed processing ${result.items.length} safe output item(s)`); + } + (async () => { try { - const { data: pullRequest } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }); - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - labels: labels, - }); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } - core.setOutput("pull_request_number", pullRequest.number); - core.setOutput("pull_request_url", pullRequest.html_url); - core.setOutput("branch_name", branchName); - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - await core.summary - .addRaw( - ` - ## Pull Request - - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - - **Branch**: \`${branchName}\` - - **Base Branch**: \`${baseBranch}\` - ` - ) - .write(); - } catch (prError) { - core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); - core.info("Falling back to creating an issue instead"); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). - **Original error:** ${prError instanceof Error ? prError.message : String(prError)} - You can manually create a pull request from the branch if needed.${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - await core.summary - .addRaw( - ` - ## Fallback Issue Created - - **Issue**: [#${issue.number}](${issue.html_url}) - - **Branch**: [\`${branchName}\`](${branchUrl}) - - **Base Branch**: \`${baseBranch}\` - - **Note**: Pull request creation failed, created issue as fallback - ` - ) - .write(); - } catch (issueError) { - core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`); - return; - } + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml index 1f479fcd45..e53e964556 100644 --- a/.github/workflows/smoke-claude.lock.yml +++ b/.github/workflows/smoke-claude.lock.yml @@ -6993,12 +6993,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} - add_labels_labels_added: ${{ steps.add_labels.outputs.labels_added }} - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7011,2383 +7015,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash - run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/safe_output_helpers.cjs << 'EOF_80a143d8' - // @ts-check - /// - - /** - * Shared helper functions for safe-output scripts - * Provides common validation and target resolution logic - */ - - /** - * Parse a comma-separated list of allowed items from environment variable - * @param {string|undefined} envValue - Environment variable value - * @returns {string[]|undefined} Array of allowed items, or undefined if no restrictions - */ - function parseAllowedItems(envValue) { - const trimmed = envValue?.trim(); - if (!trimmed) { - return undefined; - } - return trimmed - .split(",") - .map(item => item.trim()) - .filter(item => item); - } - - /** - * Parse and validate max count from environment variable - * @param {string|undefined} envValue - Environment variable value - * @param {number} defaultValue - Default value if not specified - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function parseMaxCount(envValue, defaultValue = 3) { - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - /** - * Resolve the target number (issue/PR) based on configuration and context - * @param {Object} params - Resolution parameters - * @param {string} params.targetConfig - Target configuration ("triggering", "*", or explicit number) - * @param {any} params.item - Safe output item with optional item_number or pull_request_number - * @param {any} params.context - GitHub Actions context - * @param {string} params.itemType - Type of item being processed (for error messages) - * @param {boolean} params.supportsPR - Whether this safe output supports PR context - * @returns {{success: true, number: number, contextType: string} | {success: false, error: string, shouldFail: boolean}} Resolution result - */ - function resolveTarget(params) { - const { targetConfig, item, context, itemType, supportsPR = false } = params; - - // Check context type - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - - // Default target is "triggering" - const target = targetConfig || "triggering"; - - // Validate context for triggering mode - if (target === "triggering") { - if (supportsPR) { - if (!isIssueContext && !isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in issue or pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } else { - if (!isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } - } - - // Resolve target number - let itemNumber; - let contextType; - - if (target === "*") { - // Use item_number, issue_number, or pull_request_number from item - const numberField = supportsPR ? item.item_number || item.issue_number || item.pull_request_number : item.pull_request_number; - - if (numberField) { - itemNumber = typeof numberField === "number" ? numberField : parseInt(String(numberField), 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "item_number/issue_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, - shouldFail: true, - }; - } - contextType = supportsPR && (item.item_number || item.issue_number) ? "issue" : "pull request"; - } else { - return { - success: false, - error: `Target is "*" but no ${supportsPR ? "item_number/issue_number" : "pull_request_number"} specified in ${itemType} item`, - shouldFail: true, - }; - } - } else if (target !== "triggering") { - // Explicit number - itemNumber = parseInt(target, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "issue" : "pull request"} number in target configuration: ${target}`, - shouldFail: true, - }; - } - contextType = supportsPR ? "issue" : "pull request"; - } else { - // Use triggering context - if (isIssueContext) { - if (context.payload.issue) { - itemNumber = context.payload.issue.number; - contextType = "issue"; - } else { - return { - success: false, - error: "Issue context detected but no issue found in payload", - shouldFail: true, - }; - } - } else if (isPRContext) { - if (context.payload.pull_request) { - itemNumber = context.payload.pull_request.number; - contextType = "pull request"; - } else { - return { - success: false, - error: "Pull request context detected but no pull request found in payload", - shouldFail: true, - }; - } - } - } - - if (!itemNumber) { - return { - success: false, - error: `Could not determine ${supportsPR ? "issue or pull request" : "pull request"} number`, - shouldFail: true, - }; - } - - return { - success: true, - number: itemNumber, - contextType: contextType || (supportsPR ? "issue" : "pull request"), - }; - } - - module.exports = { - parseAllowedItems, - parseMaxCount, - resolveTarget, - }; - - EOF_80a143d8 - cat > /tmp/gh-aw/scripts/safe_output_processor.cjs << 'EOF_8f3864e2' - // @ts-check - /// - - /** - * Shared processor for safe-output scripts - * Provides common pipeline: load agent output, handle staged mode, parse config, resolve target - */ - - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { parseAllowedItems, resolveTarget } = require('/tmp/gh-aw/scripts/safe_output_helpers.cjs'); - const { getSafeOutputConfig, validateMaxCount } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs'); - - /** - * @typedef {Object} ProcessorConfig - * @property {string} itemType - The type field value to match in agent output (e.g., "add_labels") - * @property {string} configKey - The key to use when reading from config.json (e.g., "add_labels") - * @property {string} displayName - Human-readable name for logging (e.g., "Add Labels") - * @property {string} itemTypeName - Name used in error messages (e.g., "label addition") - * @property {boolean} [supportsPR] - When true, allows both issue AND PR contexts; when false, only PR context (default: false) - * @property {boolean} [supportsIssue] - When true, passes supportsPR=true to resolveTarget to enable both contexts (default: false) - * @property {boolean} [findMultiple] - Whether to find multiple items instead of just one (default: false) - * @property {Object} envVars - Environment variable names - * @property {string} [envVars.allowed] - Env var for allowed items list - * @property {string} [envVars.maxCount] - Env var for max count - * @property {string} [envVars.target] - Env var for target configuration - */ - - /** - * @typedef {Object} ProcessorResult - * @property {boolean} success - Whether processing should continue - * @property {any} [item] - The found item (when findMultiple is false) - * @property {any[]} [items] - The found items (when findMultiple is true) - * @property {Object} [config] - Parsed configuration - * @property {string[]|undefined} [config.allowed] - Allowed items list - * @property {number} [config.maxCount] - Maximum count - * @property {string} [config.target] - Target configuration - * @property {Object} [targetResult] - Result from resolveTarget (when findMultiple is false) - * @property {number} [targetResult.number] - Target issue/PR number - * @property {string} [targetResult.contextType] - Type of context (issue or pull request) - * @property {string} [reason] - Reason why processing should not continue - */ - - /** - * Process the initial steps common to safe-output scripts: - * 1. Load agent output - * 2. Find matching item(s) - * 3. Handle staged mode - * 4. Parse configuration - * 5. Resolve target (for single-item processors) - * - * @param {ProcessorConfig} config - Processor configuration - * @param {Object} stagedPreviewOptions - Options for staged preview - * @param {string} stagedPreviewOptions.title - Title for staged preview - * @param {string} stagedPreviewOptions.description - Description for staged preview - * @param {(item: any, index: number) => string} stagedPreviewOptions.renderItem - Function to render item in preview - * @returns {Promise} Processing result - */ - async function processSafeOutput(config, stagedPreviewOptions) { - const { itemType, configKey, displayName, itemTypeName, supportsPR = false, supportsIssue = false, findMultiple = false, envVars } = config; - - // Step 1: Load agent output - const result = loadAgentOutput(); - if (!result.success) { - return { success: false, reason: "Agent output not available" }; - } - - // Step 2: Find matching item(s) - let items; - if (findMultiple) { - items = result.items.filter(item => item.type === itemType); - if (items.length === 0) { - core.info(`No ${itemType} items found in agent output`); - return { success: false, reason: `No ${itemType} items found` }; - } - core.info(`Found ${items.length} ${itemType} item(s)`); - } else { - const item = result.items.find(item => item.type === itemType); - if (!item) { - core.warning(`No ${itemType.replace(/_/g, "-")} item found in agent output`); - return { success: false, reason: `No ${itemType} item found` }; - } - items = [item]; - // Log item details based on common fields - const itemDetails = getItemDetails(item); - if (itemDetails) { - core.info(`Found ${itemType.replace(/_/g, "-")} item with ${itemDetails}`); - } - } - - // Step 3: Handle staged mode - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - await generateStagedPreview({ - title: stagedPreviewOptions.title, - description: stagedPreviewOptions.description, - items: items, - renderItem: stagedPreviewOptions.renderItem, - }); - return { success: false, reason: "Staged mode - preview generated" }; - } - - // Step 4: Parse configuration - const safeOutputConfig = getSafeOutputConfig(configKey); - - // Parse allowed items (from env or config) - const allowedEnvValue = envVars.allowed ? process.env[envVars.allowed] : undefined; - const allowed = parseAllowedItems(allowedEnvValue) || safeOutputConfig.allowed; - if (allowed) { - core.info(`Allowed ${itemTypeName}s: ${JSON.stringify(allowed)}`); - } else { - core.info(`No ${itemTypeName} restrictions - any ${itemTypeName}s are allowed`); - } - - // Parse max count (env takes priority, then config) - const maxCountEnvValue = envVars.maxCount ? process.env[envVars.maxCount] : undefined; - const maxCountResult = validateMaxCount(maxCountEnvValue, safeOutputConfig.max); - if (!maxCountResult.valid) { - core.setFailed(maxCountResult.error); - return { success: false, reason: "Invalid max count configuration" }; - } - const maxCount = maxCountResult.value; - core.info(`Max count: ${maxCount}`); - - // Get target configuration - const target = envVars.target ? process.env[envVars.target] || "triggering" : "triggering"; - core.info(`${displayName} target configuration: ${target}`); - - // For multiple items, return early without target resolution - if (findMultiple) { - return { - success: true, - items: items, - config: { - allowed, - maxCount, - target, - }, - }; - } - - // Step 5: Resolve target (for single-item processors) - const item = items[0]; - const targetResult = resolveTarget({ - targetConfig: target, - item: item, - context, - itemType: itemTypeName, - // supportsPR in resolveTarget: true=both issue and PR contexts, false=PR-only - // If supportsIssue is true, we pass supportsPR=true to enable both contexts - supportsPR: supportsPR || supportsIssue, - }); - - if (!targetResult.success) { - if (targetResult.shouldFail) { - core.setFailed(targetResult.error); - } else { - core.info(targetResult.error); - } - return { success: false, reason: targetResult.error }; - } - - return { - success: true, - item: item, - config: { - allowed, - maxCount, - target, - }, - targetResult: { - number: targetResult.number, - contextType: targetResult.contextType, - }, - }; - } - - /** - * Get a description of item details for logging - * @param {any} item - The safe output item - * @returns {string|null} Description string or null - */ - function getItemDetails(item) { - if (item.labels && Array.isArray(item.labels)) { - return `${item.labels.length} labels`; - } - if (item.reviewers && Array.isArray(item.reviewers)) { - return `${item.reviewers.length} reviewers`; - } - return null; - } - - /** - * Sanitize and deduplicate an array of string items - * @param {any[]} items - Raw items array - * @returns {string[]} Sanitized and deduplicated array - */ - function sanitizeItems(items) { - return items - .filter(item => item != null && item !== false && item !== 0) - .map(item => String(item).trim()) - .filter(item => item) - .filter((item, index, arr) => arr.indexOf(item) === index); - } - - /** - * Filter items by allowed list - * @param {string[]} items - Items to filter - * @param {string[]|undefined} allowed - Allowed items list (undefined means all allowed) - * @returns {string[]} Filtered items - */ - function filterByAllowed(items, allowed) { - if (!allowed || allowed.length === 0) { - return items; - } - return items.filter(item => allowed.includes(item)); - } - - /** - * Limit items to max count - * @param {string[]} items - Items to limit - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Limited items - */ - function limitToMaxCount(items, maxCount) { - if (items.length > maxCount) { - core.info(`Too many items (${items.length}), limiting to ${maxCount}`); - return items.slice(0, maxCount); - } - return items; - } - - /** - * Process items through the standard pipeline: filter by allowed, sanitize, dedupe, limit - * @param {any[]} rawItems - Raw items array from agent output - * @param {string[]|undefined} allowed - Allowed items list - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Processed items - */ - function processItems(rawItems, allowed, maxCount) { - // Filter by allowed list first - const filtered = filterByAllowed(rawItems, allowed); - - // Sanitize and deduplicate - const sanitized = sanitizeItems(filtered); - - // Limit to max count - return limitToMaxCount(sanitized, maxCount); - } - - module.exports = { - processSafeOutput, - sanitizeItems, - filterByAllowed, - limitToMaxCount, - processItems, - }; - - EOF_8f3864e2 - cat > /tmp/gh-aw/scripts/safe_output_validator.cjs << 'EOF_437e6b4f' - // @ts-check - /// - - const fs = require("fs"); - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - - /** - * Load and parse the safe outputs configuration from config.json - * @returns {object} The parsed configuration object - */ - function loadSafeOutputsConfig() { - const configPath = "/tmp/gh-aw/safeoutputs/config.json"; - try { - if (!fs.existsSync(configPath)) { - core.warning(`Config file not found at ${configPath}, using defaults`); - return {}; - } - const configContent = fs.readFileSync(configPath, "utf8"); - return JSON.parse(configContent); - } catch (error) { - core.warning(`Failed to load config: ${error instanceof Error ? error.message : String(error)}`); - return {}; - } - } - - /** - * Get configuration for a specific safe output type - * @param {string} outputType - The type of safe output (e.g., "add_labels", "update_issue") - * @returns {{max?: number, target?: string, allowed?: string[]}} The configuration for this output type - */ - function getSafeOutputConfig(outputType) { - const config = loadSafeOutputsConfig(); - return config[outputType] || {}; - } - - /** - * Validate and sanitize a title string - * @param {any} title - The title to validate - * @param {string} fieldName - The name of the field for error messages (default: "title") - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateTitle(title, fieldName = "title") { - if (title === undefined || title === null) { - return { valid: false, error: `${fieldName} is required` }; - } - - if (typeof title !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - const trimmed = title.trim(); - if (trimmed.length === 0) { - return { valid: false, error: `${fieldName} cannot be empty` }; - } - - return { valid: true, value: trimmed }; - } - - /** - * Validate and sanitize a body/content string - * @param {any} body - The body to validate - * @param {string} fieldName - The name of the field for error messages (default: "body") - * @param {boolean} required - Whether the body is required (default: false) - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateBody(body, fieldName = "body", required = false) { - if (body === undefined || body === null) { - if (required) { - return { valid: false, error: `${fieldName} is required` }; - } - return { valid: true, value: "" }; - } - - if (typeof body !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - return { valid: true, value: body }; - } - - /** - * Validate and sanitize an array of labels - * @param {any} labels - The labels to validate - * @param {string[]|undefined} allowedLabels - Optional list of allowed labels - * @param {number} maxCount - Maximum number of labels allowed - * @returns {{valid: boolean, value?: string[], error?: string}} Validation result - */ - function validateLabels(labels, allowedLabels = undefined, maxCount = 3) { - if (!labels || !Array.isArray(labels)) { - return { valid: false, error: "labels must be an array" }; - } - - // Check for removal attempts (labels starting with '-') - for (const label of labels) { - if (label && typeof label === "string" && label.startsWith("-")) { - return { valid: false, error: `Label removal is not permitted. Found line starting with '-': ${label}` }; - } - } - - // Filter labels based on allowed list if provided - let validLabels = labels; - if (allowedLabels && allowedLabels.length > 0) { - validLabels = labels.filter(label => allowedLabels.includes(label)); - } - - // Sanitize and deduplicate labels - const uniqueLabels = validLabels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - - // Apply max count limit - if (uniqueLabels.length > maxCount) { - core.info(`Too many labels (${uniqueLabels.length}), limiting to ${maxCount}`); - return { valid: true, value: uniqueLabels.slice(0, maxCount) }; - } - - if (uniqueLabels.length === 0) { - return { valid: false, error: "No valid labels found after sanitization" }; - } - - return { valid: true, value: uniqueLabels }; - } - - /** - * Validate max count from environment variable with config fallback - * @param {string|undefined} envValue - Environment variable value - * @param {number|undefined} configDefault - Default from config.json - * @param {number} [fallbackDefault] - Fallback default for testing (optional, defaults to 1) - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function validateMaxCount(envValue, configDefault, fallbackDefault = 1) { - // Priority: env var > config.json > fallback default - // In production, config.json should always have the default - // Fallback is provided for backward compatibility and testing - const defaultValue = configDefault !== undefined ? configDefault : fallbackDefault; - - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - module.exports = { - loadSafeOutputsConfig, - getSafeOutputConfig, - validateTitle, - validateBody, - validateLabels, - validateMaxCount, - }; - - EOF_437e6b4f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_ISSUE_EXPIRES: "1" - GH_AW_WORKFLOW_NAME: "Smoke Claude" - GH_AW_ENGINE_ID: "claude" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 💥 *[THE END] — Illustrated by [{workflow_name}]({run_url})*\",\"runStarted\":\"💥 **WHOOSH!** [{workflow_name}]({run_url}) springs into action on this {event_type}! *[Panel 1 begins...]*\",\"runSuccess\":\"🎬 **THE END** — [{workflow_name}]({run_url}) **MISSION: ACCOMPLISHED!** The hero saves the day! ✨\",\"runFailure\":\"💫 **TO BE CONTINUED...** [{workflow_name}]({run_url}) {status}! Our hero faces unexpected challenges...\"}" - with: +GH_AW_ISSUE_EXPIRES=1 with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; + summaryContent += `**Repository:** ${item.repo}\n\n`; } if (item.body) { - content += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; - } - return content; - }, - }); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } - } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; + summaryContent += "---\n\n"; } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; - } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; } await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); + return; } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); - } - (async () => { - await main(); - })(); - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_HIDE_OLDER_COMMENTS: "true" - GH_AW_CREATED_ISSUE_URL: ${{ steps.create_issue.outputs.issue_url }} - GH_AW_CREATED_ISSUE_NUMBER: ${{ steps.create_issue.outputs.issue_number }} - GH_AW_TEMPORARY_ID_MAP: ${{ steps.create_issue.outputs.temporary_id_map }} - GH_AW_WORKFLOW_NAME: "Smoke Claude" - GH_AW_ENGINE_ID: "claude" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 💥 *[THE END] — Illustrated by [{workflow_name}]({run_url})*\",\"runStarted\":\"💥 **WHOOSH!** [{workflow_name}]({run_url}) springs into action on this {event_type}! *[Panel 1 begins...]*\",\"runSuccess\":\"🎬 **THE END** — [{workflow_name}]({run_url}) **MISSION: ACCOMPLISHED!** The hero saves the day! ✨\",\"runFailure\":\"💫 **TO BE CONTINUED...** [{workflow_name}]({run_url}) {status}! Our hero faces unexpected challenges...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } } - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; - } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); - return; } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; - } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; - } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); - } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); - - name: Add Labels - id: add_labels - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_labels')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_LABELS_ALLOWED: "smoke-claude" - GH_AW_WORKFLOW_NAME: "Smoke Claude" - GH_AW_ENGINE_ID: "claude" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 💥 *[THE END] — Illustrated by [{workflow_name}]({run_url})*\",\"runStarted\":\"💥 **WHOOSH!** [{workflow_name}]({run_url}) springs into action on this {event_type}! *[Panel 1 begins...]*\",\"runSuccess\":\"🎬 **THE END** — [{workflow_name}]({run_url}) **MISSION: ACCOMPLISHED!** The hero saves the day! ✨\",\"runFailure\":\"💫 **TO BE CONTINUED...** [{workflow_name}]({run_url}) {status}! Our hero faces unexpected challenges...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { processSafeOutput } = require('/tmp/gh-aw/scripts/safe_output_processor.cjs'); - const { validateLabels } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs'); - async function main() { - const result = await processSafeOutput( - { - itemType: "add_labels", - configKey: "add_labels", - displayName: "Labels", - itemTypeName: "label addition", - supportsPR: true, - supportsIssue: true, - envVars: { - allowed: "GH_AW_LABELS_ALLOWED", - maxCount: "GH_AW_LABELS_MAX_COUNT", - target: "GH_AW_LABELS_TARGET", - }, - }, - { - title: "Add Labels", - description: "The following labels would be added if staged mode was disabled:", - renderItem: item => { - let content = ""; - if (item.item_number) { - content += `**Target Issue:** #${item.item_number}\n\n`; - } else { - content += `**Target:** Current issue/PR\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels to add:** ${item.labels.join(", ")}\n\n`; - } - return content; - }, - } - ); - if (!result.success) { - return; - } - const { item: labelsItem, config, targetResult } = result; - if (!config || !targetResult || targetResult.number === undefined) { - core.setFailed("Internal error: config, targetResult, or targetResult.number is undefined"); - return; - } - const { allowed: allowedLabels, maxCount } = config; - const itemNumber = targetResult.number; - const { contextType } = targetResult; - const requestedLabels = labelsItem.labels || []; - core.info(`Requested labels: ${JSON.stringify(requestedLabels)}`); - const labelsResult = validateLabels(requestedLabels, allowedLabels, maxCount); - if (!labelsResult.valid) { - if (labelsResult.error && labelsResult.error.includes("No valid labels")) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` - ## Label Addition - No labels were added (no valid labels found in agent output). - ` - ) - .write(); - return; - } - core.setFailed(labelsResult.error || "Invalid labels"); - return; - } - const uniqueLabels = labelsResult.value || []; - if (uniqueLabels.length === 0) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` - ## Label Addition - No labels were added (no valid labels found in agent output). - ` - ) - .write(); - return; - } - core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${itemNumber}: ${JSON.stringify(uniqueLabels)}`); + (async () => { try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - labels: uniqueLabels, - }); - core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${itemNumber}`); - core.setOutput("labels_added", uniqueLabels.join("\n")); - const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); - await core.summary - .addRaw( - ` - ## Label Addition - Successfully added ${uniqueLabels.length} label(s) to ${contextType} #${itemNumber}: - ${labelsListMarkdown} - ` - ) - .write(); + await main(); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to add labels: ${errorMessage}`); - core.setFailed(`Failed to add labels: ${errorMessage}`); + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml index e65f422c2f..11251813b6 100644 --- a/.github/workflows/smoke-codex.lock.yml +++ b/.github/workflows/smoke-codex.lock.yml @@ -6890,12 +6890,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} - add_labels_labels_added: ${{ steps.add_labels.outputs.labels_added }} - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6908,2485 +6912,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash - run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/safe_output_helpers.cjs << 'EOF_80a143d8' - // @ts-check - /// - - /** - * Shared helper functions for safe-output scripts - * Provides common validation and target resolution logic - */ - - /** - * Parse a comma-separated list of allowed items from environment variable - * @param {string|undefined} envValue - Environment variable value - * @returns {string[]|undefined} Array of allowed items, or undefined if no restrictions - */ - function parseAllowedItems(envValue) { - const trimmed = envValue?.trim(); - if (!trimmed) { - return undefined; - } - return trimmed - .split(",") - .map(item => item.trim()) - .filter(item => item); - } - - /** - * Parse and validate max count from environment variable - * @param {string|undefined} envValue - Environment variable value - * @param {number} defaultValue - Default value if not specified - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function parseMaxCount(envValue, defaultValue = 3) { - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - /** - * Resolve the target number (issue/PR) based on configuration and context - * @param {Object} params - Resolution parameters - * @param {string} params.targetConfig - Target configuration ("triggering", "*", or explicit number) - * @param {any} params.item - Safe output item with optional item_number or pull_request_number - * @param {any} params.context - GitHub Actions context - * @param {string} params.itemType - Type of item being processed (for error messages) - * @param {boolean} params.supportsPR - Whether this safe output supports PR context - * @returns {{success: true, number: number, contextType: string} | {success: false, error: string, shouldFail: boolean}} Resolution result - */ - function resolveTarget(params) { - const { targetConfig, item, context, itemType, supportsPR = false } = params; - - // Check context type - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - - // Default target is "triggering" - const target = targetConfig || "triggering"; - - // Validate context for triggering mode - if (target === "triggering") { - if (supportsPR) { - if (!isIssueContext && !isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in issue or pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } else { - if (!isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } - } - - // Resolve target number - let itemNumber; - let contextType; - - if (target === "*") { - // Use item_number, issue_number, or pull_request_number from item - const numberField = supportsPR ? item.item_number || item.issue_number || item.pull_request_number : item.pull_request_number; - - if (numberField) { - itemNumber = typeof numberField === "number" ? numberField : parseInt(String(numberField), 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "item_number/issue_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, - shouldFail: true, - }; - } - contextType = supportsPR && (item.item_number || item.issue_number) ? "issue" : "pull request"; - } else { - return { - success: false, - error: `Target is "*" but no ${supportsPR ? "item_number/issue_number" : "pull_request_number"} specified in ${itemType} item`, - shouldFail: true, - }; - } - } else if (target !== "triggering") { - // Explicit number - itemNumber = parseInt(target, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "issue" : "pull request"} number in target configuration: ${target}`, - shouldFail: true, - }; - } - contextType = supportsPR ? "issue" : "pull request"; - } else { - // Use triggering context - if (isIssueContext) { - if (context.payload.issue) { - itemNumber = context.payload.issue.number; - contextType = "issue"; - } else { - return { - success: false, - error: "Issue context detected but no issue found in payload", - shouldFail: true, - }; - } - } else if (isPRContext) { - if (context.payload.pull_request) { - itemNumber = context.payload.pull_request.number; - contextType = "pull request"; - } else { - return { - success: false, - error: "Pull request context detected but no pull request found in payload", - shouldFail: true, - }; - } - } - } - - if (!itemNumber) { - return { - success: false, - error: `Could not determine ${supportsPR ? "issue or pull request" : "pull request"} number`, - shouldFail: true, - }; - } - - return { - success: true, - number: itemNumber, - contextType: contextType || (supportsPR ? "issue" : "pull request"), - }; - } - - module.exports = { - parseAllowedItems, - parseMaxCount, - resolveTarget, - }; - - EOF_80a143d8 - cat > /tmp/gh-aw/scripts/safe_output_processor.cjs << 'EOF_8f3864e2' - // @ts-check - /// - - /** - * Shared processor for safe-output scripts - * Provides common pipeline: load agent output, handle staged mode, parse config, resolve target - */ - - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { parseAllowedItems, resolveTarget } = require('/tmp/gh-aw/scripts/safe_output_helpers.cjs'); - const { getSafeOutputConfig, validateMaxCount } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs'); - - /** - * @typedef {Object} ProcessorConfig - * @property {string} itemType - The type field value to match in agent output (e.g., "add_labels") - * @property {string} configKey - The key to use when reading from config.json (e.g., "add_labels") - * @property {string} displayName - Human-readable name for logging (e.g., "Add Labels") - * @property {string} itemTypeName - Name used in error messages (e.g., "label addition") - * @property {boolean} [supportsPR] - When true, allows both issue AND PR contexts; when false, only PR context (default: false) - * @property {boolean} [supportsIssue] - When true, passes supportsPR=true to resolveTarget to enable both contexts (default: false) - * @property {boolean} [findMultiple] - Whether to find multiple items instead of just one (default: false) - * @property {Object} envVars - Environment variable names - * @property {string} [envVars.allowed] - Env var for allowed items list - * @property {string} [envVars.maxCount] - Env var for max count - * @property {string} [envVars.target] - Env var for target configuration - */ - - /** - * @typedef {Object} ProcessorResult - * @property {boolean} success - Whether processing should continue - * @property {any} [item] - The found item (when findMultiple is false) - * @property {any[]} [items] - The found items (when findMultiple is true) - * @property {Object} [config] - Parsed configuration - * @property {string[]|undefined} [config.allowed] - Allowed items list - * @property {number} [config.maxCount] - Maximum count - * @property {string} [config.target] - Target configuration - * @property {Object} [targetResult] - Result from resolveTarget (when findMultiple is false) - * @property {number} [targetResult.number] - Target issue/PR number - * @property {string} [targetResult.contextType] - Type of context (issue or pull request) - * @property {string} [reason] - Reason why processing should not continue - */ - - /** - * Process the initial steps common to safe-output scripts: - * 1. Load agent output - * 2. Find matching item(s) - * 3. Handle staged mode - * 4. Parse configuration - * 5. Resolve target (for single-item processors) - * - * @param {ProcessorConfig} config - Processor configuration - * @param {Object} stagedPreviewOptions - Options for staged preview - * @param {string} stagedPreviewOptions.title - Title for staged preview - * @param {string} stagedPreviewOptions.description - Description for staged preview - * @param {(item: any, index: number) => string} stagedPreviewOptions.renderItem - Function to render item in preview - * @returns {Promise} Processing result - */ - async function processSafeOutput(config, stagedPreviewOptions) { - const { itemType, configKey, displayName, itemTypeName, supportsPR = false, supportsIssue = false, findMultiple = false, envVars } = config; - - // Step 1: Load agent output - const result = loadAgentOutput(); - if (!result.success) { - return { success: false, reason: "Agent output not available" }; - } - - // Step 2: Find matching item(s) - let items; - if (findMultiple) { - items = result.items.filter(item => item.type === itemType); - if (items.length === 0) { - core.info(`No ${itemType} items found in agent output`); - return { success: false, reason: `No ${itemType} items found` }; - } - core.info(`Found ${items.length} ${itemType} item(s)`); - } else { - const item = result.items.find(item => item.type === itemType); - if (!item) { - core.warning(`No ${itemType.replace(/_/g, "-")} item found in agent output`); - return { success: false, reason: `No ${itemType} item found` }; - } - items = [item]; - // Log item details based on common fields - const itemDetails = getItemDetails(item); - if (itemDetails) { - core.info(`Found ${itemType.replace(/_/g, "-")} item with ${itemDetails}`); - } - } - - // Step 3: Handle staged mode - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - await generateStagedPreview({ - title: stagedPreviewOptions.title, - description: stagedPreviewOptions.description, - items: items, - renderItem: stagedPreviewOptions.renderItem, - }); - return { success: false, reason: "Staged mode - preview generated" }; - } - - // Step 4: Parse configuration - const safeOutputConfig = getSafeOutputConfig(configKey); - - // Parse allowed items (from env or config) - const allowedEnvValue = envVars.allowed ? process.env[envVars.allowed] : undefined; - const allowed = parseAllowedItems(allowedEnvValue) || safeOutputConfig.allowed; - if (allowed) { - core.info(`Allowed ${itemTypeName}s: ${JSON.stringify(allowed)}`); - } else { - core.info(`No ${itemTypeName} restrictions - any ${itemTypeName}s are allowed`); - } - - // Parse max count (env takes priority, then config) - const maxCountEnvValue = envVars.maxCount ? process.env[envVars.maxCount] : undefined; - const maxCountResult = validateMaxCount(maxCountEnvValue, safeOutputConfig.max); - if (!maxCountResult.valid) { - core.setFailed(maxCountResult.error); - return { success: false, reason: "Invalid max count configuration" }; - } - const maxCount = maxCountResult.value; - core.info(`Max count: ${maxCount}`); - - // Get target configuration - const target = envVars.target ? process.env[envVars.target] || "triggering" : "triggering"; - core.info(`${displayName} target configuration: ${target}`); - - // For multiple items, return early without target resolution - if (findMultiple) { - return { - success: true, - items: items, - config: { - allowed, - maxCount, - target, - }, - }; - } - - // Step 5: Resolve target (for single-item processors) - const item = items[0]; - const targetResult = resolveTarget({ - targetConfig: target, - item: item, - context, - itemType: itemTypeName, - // supportsPR in resolveTarget: true=both issue and PR contexts, false=PR-only - // If supportsIssue is true, we pass supportsPR=true to enable both contexts - supportsPR: supportsPR || supportsIssue, - }); - - if (!targetResult.success) { - if (targetResult.shouldFail) { - core.setFailed(targetResult.error); - } else { - core.info(targetResult.error); - } - return { success: false, reason: targetResult.error }; - } - - return { - success: true, - item: item, - config: { - allowed, - maxCount, - target, - }, - targetResult: { - number: targetResult.number, - contextType: targetResult.contextType, - }, - }; - } - - /** - * Get a description of item details for logging - * @param {any} item - The safe output item - * @returns {string|null} Description string or null - */ - function getItemDetails(item) { - if (item.labels && Array.isArray(item.labels)) { - return `${item.labels.length} labels`; - } - if (item.reviewers && Array.isArray(item.reviewers)) { - return `${item.reviewers.length} reviewers`; - } - return null; - } - - /** - * Sanitize and deduplicate an array of string items - * @param {any[]} items - Raw items array - * @returns {string[]} Sanitized and deduplicated array - */ - function sanitizeItems(items) { - return items - .filter(item => item != null && item !== false && item !== 0) - .map(item => String(item).trim()) - .filter(item => item) - .filter((item, index, arr) => arr.indexOf(item) === index); - } - - /** - * Filter items by allowed list - * @param {string[]} items - Items to filter - * @param {string[]|undefined} allowed - Allowed items list (undefined means all allowed) - * @returns {string[]} Filtered items - */ - function filterByAllowed(items, allowed) { - if (!allowed || allowed.length === 0) { - return items; - } - return items.filter(item => allowed.includes(item)); - } - - /** - * Limit items to max count - * @param {string[]} items - Items to limit - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Limited items - */ - function limitToMaxCount(items, maxCount) { - if (items.length > maxCount) { - core.info(`Too many items (${items.length}), limiting to ${maxCount}`); - return items.slice(0, maxCount); - } - return items; - } - - /** - * Process items through the standard pipeline: filter by allowed, sanitize, dedupe, limit - * @param {any[]} rawItems - Raw items array from agent output - * @param {string[]|undefined} allowed - Allowed items list - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Processed items - */ - function processItems(rawItems, allowed, maxCount) { - // Filter by allowed list first - const filtered = filterByAllowed(rawItems, allowed); - - // Sanitize and deduplicate - const sanitized = sanitizeItems(filtered); - - // Limit to max count - return limitToMaxCount(sanitized, maxCount); - } - - module.exports = { - processSafeOutput, - sanitizeItems, - filterByAllowed, - limitToMaxCount, - processItems, - }; - - EOF_8f3864e2 - cat > /tmp/gh-aw/scripts/safe_output_validator.cjs << 'EOF_437e6b4f' - // @ts-check - /// - - const fs = require("fs"); - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - - /** - * Load and parse the safe outputs configuration from config.json - * @returns {object} The parsed configuration object - */ - function loadSafeOutputsConfig() { - const configPath = "/tmp/gh-aw/safeoutputs/config.json"; - try { - if (!fs.existsSync(configPath)) { - core.warning(`Config file not found at ${configPath}, using defaults`); - return {}; - } - const configContent = fs.readFileSync(configPath, "utf8"); - return JSON.parse(configContent); - } catch (error) { - core.warning(`Failed to load config: ${error instanceof Error ? error.message : String(error)}`); - return {}; - } - } - - /** - * Get configuration for a specific safe output type - * @param {string} outputType - The type of safe output (e.g., "add_labels", "update_issue") - * @returns {{max?: number, target?: string, allowed?: string[]}} The configuration for this output type - */ - function getSafeOutputConfig(outputType) { - const config = loadSafeOutputsConfig(); - return config[outputType] || {}; - } - - /** - * Validate and sanitize a title string - * @param {any} title - The title to validate - * @param {string} fieldName - The name of the field for error messages (default: "title") - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateTitle(title, fieldName = "title") { - if (title === undefined || title === null) { - return { valid: false, error: `${fieldName} is required` }; - } - - if (typeof title !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - const trimmed = title.trim(); - if (trimmed.length === 0) { - return { valid: false, error: `${fieldName} cannot be empty` }; - } - - return { valid: true, value: trimmed }; - } - - /** - * Validate and sanitize a body/content string - * @param {any} body - The body to validate - * @param {string} fieldName - The name of the field for error messages (default: "body") - * @param {boolean} required - Whether the body is required (default: false) - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateBody(body, fieldName = "body", required = false) { - if (body === undefined || body === null) { - if (required) { - return { valid: false, error: `${fieldName} is required` }; - } - return { valid: true, value: "" }; - } - - if (typeof body !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - return { valid: true, value: body }; - } - - /** - * Validate and sanitize an array of labels - * @param {any} labels - The labels to validate - * @param {string[]|undefined} allowedLabels - Optional list of allowed labels - * @param {number} maxCount - Maximum number of labels allowed - * @returns {{valid: boolean, value?: string[], error?: string}} Validation result - */ - function validateLabels(labels, allowedLabels = undefined, maxCount = 3) { - if (!labels || !Array.isArray(labels)) { - return { valid: false, error: "labels must be an array" }; - } - - // Check for removal attempts (labels starting with '-') - for (const label of labels) { - if (label && typeof label === "string" && label.startsWith("-")) { - return { valid: false, error: `Label removal is not permitted. Found line starting with '-': ${label}` }; - } - } - - // Filter labels based on allowed list if provided - let validLabels = labels; - if (allowedLabels && allowedLabels.length > 0) { - validLabels = labels.filter(label => allowedLabels.includes(label)); - } - - // Sanitize and deduplicate labels - const uniqueLabels = validLabels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - - // Apply max count limit - if (uniqueLabels.length > maxCount) { - core.info(`Too many labels (${uniqueLabels.length}), limiting to ${maxCount}`); - return { valid: true, value: uniqueLabels.slice(0, maxCount) }; - } - - if (uniqueLabels.length === 0) { - return { valid: false, error: "No valid labels found after sanitization" }; - } - - return { valid: true, value: uniqueLabels }; - } - - /** - * Validate max count from environment variable with config fallback - * @param {string|undefined} envValue - Environment variable value - * @param {number|undefined} configDefault - Default from config.json - * @param {number} [fallbackDefault] - Fallback default for testing (optional, defaults to 1) - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function validateMaxCount(envValue, configDefault, fallbackDefault = 1) { - // Priority: env var > config.json > fallback default - // In production, config.json should always have the default - // Fallback is provided for backward compatibility and testing - const defaultValue = configDefault !== undefined ? configDefault : fallbackDefault; - - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - module.exports = { - loadSafeOutputsConfig, - getSafeOutputConfig, - validateTitle, - validateBody, - validateLabels, - validateMaxCount, - }; - - EOF_437e6b4f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_ISSUE_EXPIRES: "1" - GH_AW_WORKFLOW_NAME: "Smoke Codex" - GH_AW_ENGINE_ID: "codex" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🔮 *The oracle has spoken through [{workflow_name}]({run_url})*\",\"runStarted\":\"🔮 The ancient spirits stir... [{workflow_name}]({run_url}) awakens to divine this {event_type}...\",\"runSuccess\":\"✨ The prophecy is fulfilled... [{workflow_name}]({run_url}) has completed its mystical journey. The stars align. 🌟\",\"runFailure\":\"🌑 The shadows whisper... [{workflow_name}]({run_url}) {status}. The oracle requires further meditation...\"}" - with: +GH_AW_ISSUE_EXPIRES=1 with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; + summaryContent += `**Repository:** ${item.repo}\n\n`; } if (item.body) { - content += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; - } - return content; - }, - }); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } - } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; + summaryContent += "---\n\n"; } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; - } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; } await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); + return; } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); - } - (async () => { - await main(); - })(); - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_HIDE_OLDER_COMMENTS: "true" - GH_AW_CREATED_ISSUE_URL: ${{ steps.create_issue.outputs.issue_url }} - GH_AW_CREATED_ISSUE_NUMBER: ${{ steps.create_issue.outputs.issue_number }} - GH_AW_TEMPORARY_ID_MAP: ${{ steps.create_issue.outputs.temporary_id_map }} - GH_AW_WORKFLOW_NAME: "Smoke Codex" - GH_AW_ENGINE_ID: "codex" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🔮 *The oracle has spoken through [{workflow_name}]({run_url})*\",\"runStarted\":\"🔮 The ancient spirits stir... [{workflow_name}]({run_url}) awakens to divine this {event_type}...\",\"runSuccess\":\"✨ The prophecy is fulfilled... [{workflow_name}]({run_url}) has completed its mystical journey. The stars align. 🌟\",\"runFailure\":\"🌑 The shadows whisper... [{workflow_name}]({run_url}) {status}. The oracle requires further meditation...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); } - page++; } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); - } - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; - } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; - } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); - return; - } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; - } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; - } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; - } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); - } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); - } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); - } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); - - name: Add Labels - id: add_labels - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_labels')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_LABELS_ALLOWED: "smoke-codex" - GH_AW_WORKFLOW_NAME: "Smoke Codex" - GH_AW_ENGINE_ID: "codex" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🔮 *The oracle has spoken through [{workflow_name}]({run_url})*\",\"runStarted\":\"🔮 The ancient spirits stir... [{workflow_name}]({run_url}) awakens to divine this {event_type}...\",\"runSuccess\":\"✨ The prophecy is fulfilled... [{workflow_name}]({run_url}) has completed its mystical journey. The stars align. 🌟\",\"runFailure\":\"🌑 The shadows whisper... [{workflow_name}]({run_url}) {status}. The oracle requires further meditation...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { processSafeOutput } = require('/tmp/gh-aw/scripts/safe_output_processor.cjs'); - const { validateLabels } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs'); - async function main() { - const result = await processSafeOutput( - { - itemType: "add_labels", - configKey: "add_labels", - displayName: "Labels", - itemTypeName: "label addition", - supportsPR: true, - supportsIssue: true, - envVars: { - allowed: "GH_AW_LABELS_ALLOWED", - maxCount: "GH_AW_LABELS_MAX_COUNT", - target: "GH_AW_LABELS_TARGET", - }, - }, - { - title: "Add Labels", - description: "The following labels would be added if staged mode was disabled:", - renderItem: item => { - let content = ""; - if (item.item_number) { - content += `**Target Issue:** #${item.item_number}\n\n`; - } else { - content += `**Target:** Current issue/PR\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels to add:** ${item.labels.join(", ")}\n\n`; - } - return content; - }, - } - ); - if (!result.success) { - return; - } - const { item: labelsItem, config, targetResult } = result; - if (!config || !targetResult || targetResult.number === undefined) { - core.setFailed("Internal error: config, targetResult, or targetResult.number is undefined"); - return; - } - const { allowed: allowedLabels, maxCount } = config; - const itemNumber = targetResult.number; - const { contextType } = targetResult; - const requestedLabels = labelsItem.labels || []; - core.info(`Requested labels: ${JSON.stringify(requestedLabels)}`); - const labelsResult = validateLabels(requestedLabels, allowedLabels, maxCount); - if (!labelsResult.valid) { - if (labelsResult.error && labelsResult.error.includes("No valid labels")) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` - ## Label Addition - No labels were added (no valid labels found in agent output). - ` - ) - .write(); - return; - } - core.setFailed(labelsResult.error || "Invalid labels"); - return; - } - const uniqueLabels = labelsResult.value || []; - if (uniqueLabels.length === 0) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` - ## Label Addition - No labels were added (no valid labels found in agent output). - ` - ) - .write(); - return; - } - core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${itemNumber}: ${JSON.stringify(uniqueLabels)}`); + (async () => { try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - labels: uniqueLabels, - }); - core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${itemNumber}`); - core.setOutput("labels_added", uniqueLabels.join("\n")); - const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); - await core.summary - .addRaw( - ` - ## Label Addition - Successfully added ${uniqueLabels.length} label(s) to ${contextType} #${itemNumber}: - ${labelsListMarkdown} - ` - ) - .write(); + await main(); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to add labels: ${errorMessage}`); - core.setFailed(`Failed to add labels: ${errorMessage}`); - } - } - (async () => { await main(); })(); - - name: Hide Comment - id: hide_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'hide_comment')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Smoke Codex" - GH_AW_ENGINE_ID: "codex" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🔮 *The oracle has spoken through [{workflow_name}]({run_url})*\",\"runStarted\":\"🔮 The ancient spirits stir... [{workflow_name}]({run_url}) awakens to divine this {event_type}...\",\"runSuccess\":\"✨ The prophecy is fulfilled... [{workflow_name}]({run_url}) has completed its mystical journey. The stars align. 🌟\",\"runFailure\":\"🌑 The shadows whisper... [{workflow_name}]({run_url}) {status}. The oracle requires further meditation...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - async function hideComment(github, nodeId, reason = "spam") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - let allowedReasons = null; - if (process.env.GH_AW_HIDE_COMMENT_ALLOWED_REASONS) { - try { - allowedReasons = JSON.parse(process.env.GH_AW_HIDE_COMMENT_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${allowedReasons.join(", ")}]`); - } catch (error) { - core.warning(`Failed to parse GH_AW_HIDE_COMMENT_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - } - } - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const hideCommentItems = result.items.filter( item => item.type === "hide_comment"); - if (hideCommentItems.length === 0) { - core.info("No hide-comment items found in agent output"); - return; - } - core.info(`Found ${hideCommentItems.length} hide-comment item(s)`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Hide Comments Preview\n\n"; - summaryContent += "The following comments would be hidden if staged mode was disabled:\n\n"; - for (let i = 0; i < hideCommentItems.length; i++) { - const item = hideCommentItems[i]; - const reason = item.reason || "spam"; - summaryContent += `### Comment ${i + 1}\n`; - summaryContent += `**Node ID**: ${item.comment_id}\n`; - summaryContent += `**Action**: Would be hidden as ${reason}\n`; - summaryContent += "\n"; - } - core.summary.addRaw(summaryContent).write(); - return; - } - for (const item of hideCommentItems) { - try { - const commentId = item.comment_id; - if (!commentId || typeof commentId !== "string") { - throw new Error("comment_id is required and must be a string (GraphQL node ID)"); - } - const reason = item.reason || "spam"; - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping comment ${commentId}.`); - continue; - } - } - core.info(`Hiding comment: ${commentId} (reason: ${normalizedReason})`); - const hideResult = await hideComment(github, commentId, normalizedReason); - if (hideResult.isMinimized) { - core.info(`Successfully hidden comment: ${commentId}`); - core.setOutput("comment_id", commentId); - core.setOutput("is_hidden", "true"); - } else { - throw new Error(`Failed to hide comment: ${commentId}`); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to hide comment: ${errorMessage}`); - core.setFailed(`Failed to hide comment: ${errorMessage}`); - return; - } + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/smoke-copilot-no-firewall.lock.yml b/.github/workflows/smoke-copilot-no-firewall.lock.yml index db78e27369..2542beffcf 100644 --- a/.github/workflows/smoke-copilot-no-firewall.lock.yml +++ b/.github/workflows/smoke-copilot-no-firewall.lock.yml @@ -8296,9 +8296,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} - add_labels_labels_added: ${{ steps.add_labels.outputs.labels_added }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -8311,1813 +8318,175 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/safe_output_helpers.cjs << 'EOF_80a143d8' - // @ts-check - /// - - /** - * Shared helper functions for safe-output scripts - * Provides common validation and target resolution logic - */ - - /** - * Parse a comma-separated list of allowed items from environment variable - * @param {string|undefined} envValue - Environment variable value - * @returns {string[]|undefined} Array of allowed items, or undefined if no restrictions - */ - function parseAllowedItems(envValue) { - const trimmed = envValue?.trim(); - if (!trimmed) { - return undefined; - } - return trimmed - .split(",") - .map(item => item.trim()) - .filter(item => item); - } - - /** - * Parse and validate max count from environment variable - * @param {string|undefined} envValue - Environment variable value - * @param {number} defaultValue - Default value if not specified - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function parseMaxCount(envValue, defaultValue = 3) { - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - /** - * Resolve the target number (issue/PR) based on configuration and context - * @param {Object} params - Resolution parameters - * @param {string} params.targetConfig - Target configuration ("triggering", "*", or explicit number) - * @param {any} params.item - Safe output item with optional item_number or pull_request_number - * @param {any} params.context - GitHub Actions context - * @param {string} params.itemType - Type of item being processed (for error messages) - * @param {boolean} params.supportsPR - Whether this safe output supports PR context - * @returns {{success: true, number: number, contextType: string} | {success: false, error: string, shouldFail: boolean}} Resolution result - */ - function resolveTarget(params) { - const { targetConfig, item, context, itemType, supportsPR = false } = params; - - // Check context type - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - - // Default target is "triggering" - const target = targetConfig || "triggering"; - - // Validate context for triggering mode - if (target === "triggering") { - if (supportsPR) { - if (!isIssueContext && !isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in issue or pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } else { - if (!isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } - } - - // Resolve target number - let itemNumber; - let contextType; - - if (target === "*") { - // Use item_number, issue_number, or pull_request_number from item - const numberField = supportsPR ? item.item_number || item.issue_number || item.pull_request_number : item.pull_request_number; - - if (numberField) { - itemNumber = typeof numberField === "number" ? numberField : parseInt(String(numberField), 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "item_number/issue_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, - shouldFail: true, - }; - } - contextType = supportsPR && (item.item_number || item.issue_number) ? "issue" : "pull request"; - } else { - return { - success: false, - error: `Target is "*" but no ${supportsPR ? "item_number/issue_number" : "pull_request_number"} specified in ${itemType} item`, - shouldFail: true, - }; - } - } else if (target !== "triggering") { - // Explicit number - itemNumber = parseInt(target, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "issue" : "pull request"} number in target configuration: ${target}`, - shouldFail: true, - }; - } - contextType = supportsPR ? "issue" : "pull request"; - } else { - // Use triggering context - if (isIssueContext) { - if (context.payload.issue) { - itemNumber = context.payload.issue.number; - contextType = "issue"; - } else { - return { - success: false, - error: "Issue context detected but no issue found in payload", - shouldFail: true, - }; - } - } else if (isPRContext) { - if (context.payload.pull_request) { - itemNumber = context.payload.pull_request.number; - contextType = "pull request"; - } else { - return { - success: false, - error: "Pull request context detected but no pull request found in payload", - shouldFail: true, - }; - } - } - } - - if (!itemNumber) { - return { - success: false, - error: `Could not determine ${supportsPR ? "issue or pull request" : "pull request"} number`, - shouldFail: true, - }; - } - - return { - success: true, - number: itemNumber, - contextType: contextType || (supportsPR ? "issue" : "pull request"), - }; - } - - module.exports = { - parseAllowedItems, - parseMaxCount, - resolveTarget, - }; - - EOF_80a143d8 - cat > /tmp/gh-aw/scripts/safe_output_processor.cjs << 'EOF_8f3864e2' - // @ts-check - /// - - /** - * Shared processor for safe-output scripts - * Provides common pipeline: load agent output, handle staged mode, parse config, resolve target - */ - - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { parseAllowedItems, resolveTarget } = require('/tmp/gh-aw/scripts/safe_output_helpers.cjs'); - const { getSafeOutputConfig, validateMaxCount } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs'); - - /** - * @typedef {Object} ProcessorConfig - * @property {string} itemType - The type field value to match in agent output (e.g., "add_labels") - * @property {string} configKey - The key to use when reading from config.json (e.g., "add_labels") - * @property {string} displayName - Human-readable name for logging (e.g., "Add Labels") - * @property {string} itemTypeName - Name used in error messages (e.g., "label addition") - * @property {boolean} [supportsPR] - When true, allows both issue AND PR contexts; when false, only PR context (default: false) - * @property {boolean} [supportsIssue] - When true, passes supportsPR=true to resolveTarget to enable both contexts (default: false) - * @property {boolean} [findMultiple] - Whether to find multiple items instead of just one (default: false) - * @property {Object} envVars - Environment variable names - * @property {string} [envVars.allowed] - Env var for allowed items list - * @property {string} [envVars.maxCount] - Env var for max count - * @property {string} [envVars.target] - Env var for target configuration - */ - - /** - * @typedef {Object} ProcessorResult - * @property {boolean} success - Whether processing should continue - * @property {any} [item] - The found item (when findMultiple is false) - * @property {any[]} [items] - The found items (when findMultiple is true) - * @property {Object} [config] - Parsed configuration - * @property {string[]|undefined} [config.allowed] - Allowed items list - * @property {number} [config.maxCount] - Maximum count - * @property {string} [config.target] - Target configuration - * @property {Object} [targetResult] - Result from resolveTarget (when findMultiple is false) - * @property {number} [targetResult.number] - Target issue/PR number - * @property {string} [targetResult.contextType] - Type of context (issue or pull request) - * @property {string} [reason] - Reason why processing should not continue - */ - - /** - * Process the initial steps common to safe-output scripts: - * 1. Load agent output - * 2. Find matching item(s) - * 3. Handle staged mode - * 4. Parse configuration - * 5. Resolve target (for single-item processors) - * - * @param {ProcessorConfig} config - Processor configuration - * @param {Object} stagedPreviewOptions - Options for staged preview - * @param {string} stagedPreviewOptions.title - Title for staged preview - * @param {string} stagedPreviewOptions.description - Description for staged preview - * @param {(item: any, index: number) => string} stagedPreviewOptions.renderItem - Function to render item in preview - * @returns {Promise} Processing result - */ - async function processSafeOutput(config, stagedPreviewOptions) { - const { itemType, configKey, displayName, itemTypeName, supportsPR = false, supportsIssue = false, findMultiple = false, envVars } = config; - - // Step 1: Load agent output - const result = loadAgentOutput(); - if (!result.success) { - return { success: false, reason: "Agent output not available" }; - } - - // Step 2: Find matching item(s) - let items; - if (findMultiple) { - items = result.items.filter(item => item.type === itemType); - if (items.length === 0) { - core.info(`No ${itemType} items found in agent output`); - return { success: false, reason: `No ${itemType} items found` }; - } - core.info(`Found ${items.length} ${itemType} item(s)`); - } else { - const item = result.items.find(item => item.type === itemType); - if (!item) { - core.warning(`No ${itemType.replace(/_/g, "-")} item found in agent output`); - return { success: false, reason: `No ${itemType} item found` }; - } - items = [item]; - // Log item details based on common fields - const itemDetails = getItemDetails(item); - if (itemDetails) { - core.info(`Found ${itemType.replace(/_/g, "-")} item with ${itemDetails}`); - } - } - - // Step 3: Handle staged mode - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - await generateStagedPreview({ - title: stagedPreviewOptions.title, - description: stagedPreviewOptions.description, - items: items, - renderItem: stagedPreviewOptions.renderItem, - }); - return { success: false, reason: "Staged mode - preview generated" }; - } - - // Step 4: Parse configuration - const safeOutputConfig = getSafeOutputConfig(configKey); - - // Parse allowed items (from env or config) - const allowedEnvValue = envVars.allowed ? process.env[envVars.allowed] : undefined; - const allowed = parseAllowedItems(allowedEnvValue) || safeOutputConfig.allowed; - if (allowed) { - core.info(`Allowed ${itemTypeName}s: ${JSON.stringify(allowed)}`); - } else { - core.info(`No ${itemTypeName} restrictions - any ${itemTypeName}s are allowed`); - } - - // Parse max count (env takes priority, then config) - const maxCountEnvValue = envVars.maxCount ? process.env[envVars.maxCount] : undefined; - const maxCountResult = validateMaxCount(maxCountEnvValue, safeOutputConfig.max); - if (!maxCountResult.valid) { - core.setFailed(maxCountResult.error); - return { success: false, reason: "Invalid max count configuration" }; - } - const maxCount = maxCountResult.value; - core.info(`Max count: ${maxCount}`); - - // Get target configuration - const target = envVars.target ? process.env[envVars.target] || "triggering" : "triggering"; - core.info(`${displayName} target configuration: ${target}`); - - // For multiple items, return early without target resolution - if (findMultiple) { - return { - success: true, - items: items, - config: { - allowed, - maxCount, - target, - }, - }; - } - - // Step 5: Resolve target (for single-item processors) - const item = items[0]; - const targetResult = resolveTarget({ - targetConfig: target, - item: item, - context, - itemType: itemTypeName, - // supportsPR in resolveTarget: true=both issue and PR contexts, false=PR-only - // If supportsIssue is true, we pass supportsPR=true to enable both contexts - supportsPR: supportsPR || supportsIssue, - }); - - if (!targetResult.success) { - if (targetResult.shouldFail) { - core.setFailed(targetResult.error); - } else { - core.info(targetResult.error); - } - return { success: false, reason: targetResult.error }; - } - - return { - success: true, - item: item, - config: { - allowed, - maxCount, - target, - }, - targetResult: { - number: targetResult.number, - contextType: targetResult.contextType, - }, - }; - } - - /** - * Get a description of item details for logging - * @param {any} item - The safe output item - * @returns {string|null} Description string or null - */ - function getItemDetails(item) { - if (item.labels && Array.isArray(item.labels)) { - return `${item.labels.length} labels`; - } - if (item.reviewers && Array.isArray(item.reviewers)) { - return `${item.reviewers.length} reviewers`; - } - return null; - } - - /** - * Sanitize and deduplicate an array of string items - * @param {any[]} items - Raw items array - * @returns {string[]} Sanitized and deduplicated array - */ - function sanitizeItems(items) { - return items - .filter(item => item != null && item !== false && item !== 0) - .map(item => String(item).trim()) - .filter(item => item) - .filter((item, index, arr) => arr.indexOf(item) === index); - } - - /** - * Filter items by allowed list - * @param {string[]} items - Items to filter - * @param {string[]|undefined} allowed - Allowed items list (undefined means all allowed) - * @returns {string[]} Filtered items - */ - function filterByAllowed(items, allowed) { - if (!allowed || allowed.length === 0) { - return items; - } - return items.filter(item => allowed.includes(item)); - } - - /** - * Limit items to max count - * @param {string[]} items - Items to limit - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Limited items - */ - function limitToMaxCount(items, maxCount) { - if (items.length > maxCount) { - core.info(`Too many items (${items.length}), limiting to ${maxCount}`); - return items.slice(0, maxCount); - } - return items; - } - - /** - * Process items through the standard pipeline: filter by allowed, sanitize, dedupe, limit - * @param {any[]} rawItems - Raw items array from agent output - * @param {string[]|undefined} allowed - Allowed items list - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Processed items - */ - function processItems(rawItems, allowed, maxCount) { - // Filter by allowed list first - const filtered = filterByAllowed(rawItems, allowed); - - // Sanitize and deduplicate - const sanitized = sanitizeItems(filtered); - - // Limit to max count - return limitToMaxCount(sanitized, maxCount); - } - - module.exports = { - processSafeOutput, - sanitizeItems, - filterByAllowed, - limitToMaxCount, - processItems, - }; - - EOF_8f3864e2 - cat > /tmp/gh-aw/scripts/safe_output_validator.cjs << 'EOF_437e6b4f' - // @ts-check - /// - - const fs = require("fs"); - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - - /** - * Load and parse the safe outputs configuration from config.json - * @returns {object} The parsed configuration object - */ - function loadSafeOutputsConfig() { - const configPath = "/tmp/gh-aw/safeoutputs/config.json"; - try { - if (!fs.existsSync(configPath)) { - core.warning(`Config file not found at ${configPath}, using defaults`); - return {}; - } - const configContent = fs.readFileSync(configPath, "utf8"); - return JSON.parse(configContent); - } catch (error) { - core.warning(`Failed to load config: ${error instanceof Error ? error.message : String(error)}`); - return {}; - } - } - - /** - * Get configuration for a specific safe output type - * @param {string} outputType - The type of safe output (e.g., "add_labels", "update_issue") - * @returns {{max?: number, target?: string, allowed?: string[]}} The configuration for this output type - */ - function getSafeOutputConfig(outputType) { - const config = loadSafeOutputsConfig(); - return config[outputType] || {}; - } - - /** - * Validate and sanitize a title string - * @param {any} title - The title to validate - * @param {string} fieldName - The name of the field for error messages (default: "title") - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateTitle(title, fieldName = "title") { - if (title === undefined || title === null) { - return { valid: false, error: `${fieldName} is required` }; - } - - if (typeof title !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - const trimmed = title.trim(); - if (trimmed.length === 0) { - return { valid: false, error: `${fieldName} cannot be empty` }; - } - - return { valid: true, value: trimmed }; - } - - /** - * Validate and sanitize a body/content string - * @param {any} body - The body to validate - * @param {string} fieldName - The name of the field for error messages (default: "body") - * @param {boolean} required - Whether the body is required (default: false) - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateBody(body, fieldName = "body", required = false) { - if (body === undefined || body === null) { - if (required) { - return { valid: false, error: `${fieldName} is required` }; - } - return { valid: true, value: "" }; - } - - if (typeof body !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - return { valid: true, value: body }; - } - - /** - * Validate and sanitize an array of labels - * @param {any} labels - The labels to validate - * @param {string[]|undefined} allowedLabels - Optional list of allowed labels - * @param {number} maxCount - Maximum number of labels allowed - * @returns {{valid: boolean, value?: string[], error?: string}} Validation result - */ - function validateLabels(labels, allowedLabels = undefined, maxCount = 3) { - if (!labels || !Array.isArray(labels)) { - return { valid: false, error: "labels must be an array" }; - } - - // Check for removal attempts (labels starting with '-') - for (const label of labels) { - if (label && typeof label === "string" && label.startsWith("-")) { - return { valid: false, error: `Label removal is not permitted. Found line starting with '-': ${label}` }; - } - } - - // Filter labels based on allowed list if provided - let validLabels = labels; - if (allowedLabels && allowedLabels.length > 0) { - validLabels = labels.filter(label => allowedLabels.includes(label)); - } - - // Sanitize and deduplicate labels - const uniqueLabels = validLabels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - - // Apply max count limit - if (uniqueLabels.length > maxCount) { - core.info(`Too many labels (${uniqueLabels.length}), limiting to ${maxCount}`); - return { valid: true, value: uniqueLabels.slice(0, maxCount) }; - } - - if (uniqueLabels.length === 0) { - return { valid: false, error: "No valid labels found after sanitization" }; - } - - return { valid: true, value: uniqueLabels }; - } - - /** - * Validate max count from environment variable with config fallback - * @param {string|undefined} envValue - Environment variable value - * @param {number|undefined} configDefault - Default from config.json - * @param {number} [fallbackDefault] - Fallback default for testing (optional, defaults to 1) - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function validateMaxCount(envValue, configDefault, fallbackDefault = 1) { - // Priority: env var > config.json > fallback default - // In production, config.json should always have the default - // Fallback is provided for backward compatibility and testing - const defaultValue = configDefault !== undefined ? configDefault : fallbackDefault; - - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - module.exports = { - loadSafeOutputsConfig, - getSafeOutputConfig, - validateTitle, - validateBody, - validateLabels, - validateMaxCount, - }; - - EOF_437e6b4f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_HIDE_OLDER_COMMENTS: "true" - GH_AW_WORKFLOW_NAME: "Smoke Copilot No Firewall" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🤖 *DIAGNOSTIC REPORT GENERATED BY [{workflow_name}]({run_url})*\",\"runStarted\":\"🤖 SYSTEM_INIT: [{workflow_name}]({run_url}) ACTIVATED. PROCESSING {event_type}. ALL SUBSYSTEMS ONLINE.\",\"runSuccess\":\"🤖 DIAGNOSTIC COMPLETE: [{workflow_name}]({run_url}) STATUS: ALL_UNITS_OPERATIONAL. MISSION_SUCCESS.\",\"runFailure\":\"🤖 ALERT: [{workflow_name}]({run_url}) {status}. ANOMALY_DETECTED. REPAIR_REQUIRED.\"}" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } - } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; - } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); - } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); - - name: Add Labels - id: add_labels - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_labels')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_LABELS_ALLOWED: "smoke-copilot-no-firewall" - GH_AW_WORKFLOW_NAME: "Smoke Copilot No Firewall" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🤖 *DIAGNOSTIC REPORT GENERATED BY [{workflow_name}]({run_url})*\",\"runStarted\":\"🤖 SYSTEM_INIT: [{workflow_name}]({run_url}) ACTIVATED. PROCESSING {event_type}. ALL SUBSYSTEMS ONLINE.\",\"runSuccess\":\"🤖 DIAGNOSTIC COMPLETE: [{workflow_name}]({run_url}) STATUS: ALL_UNITS_OPERATIONAL. MISSION_SUCCESS.\",\"runFailure\":\"🤖 ALERT: [{workflow_name}]({run_url}) {status}. ANOMALY_DETECTED. REPAIR_REQUIRED.\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { processSafeOutput } = require('/tmp/gh-aw/scripts/safe_output_processor.cjs'); - const { validateLabels } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs'); - async function main() { - const result = await processSafeOutput( - { - itemType: "add_labels", - configKey: "add_labels", - displayName: "Labels", - itemTypeName: "label addition", - supportsPR: true, - supportsIssue: true, - envVars: { - allowed: "GH_AW_LABELS_ALLOWED", - maxCount: "GH_AW_LABELS_MAX_COUNT", - target: "GH_AW_LABELS_TARGET", - }, - }, - { - title: "Add Labels", - description: "The following labels would be added if staged mode was disabled:", - renderItem: item => { - let content = ""; - if (item.item_number) { - content += `**Target Issue:** #${item.item_number}\n\n`; - } else { - content += `**Target:** Current issue/PR\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels to add:** ${item.labels.join(", ")}\n\n`; - } - return content; - }, - } - ); - if (!result.success) { - return; - } - const { item: labelsItem, config, targetResult } = result; - if (!config || !targetResult || targetResult.number === undefined) { - core.setFailed("Internal error: config, targetResult, or targetResult.number is undefined"); - return; - } - const { allowed: allowedLabels, maxCount } = config; - const itemNumber = targetResult.number; - const { contextType } = targetResult; - const requestedLabels = labelsItem.labels || []; - core.info(`Requested labels: ${JSON.stringify(requestedLabels)}`); - const labelsResult = validateLabels(requestedLabels, allowedLabels, maxCount); - if (!labelsResult.valid) { - if (labelsResult.error && labelsResult.error.includes("No valid labels")) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` - ## Label Addition - No labels were added (no valid labels found in agent output). - ` - ) - .write(); - return; - } - core.setFailed(labelsResult.error || "Invalid labels"); - return; - } - const uniqueLabels = labelsResult.value || []; - if (uniqueLabels.length === 0) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` - ## Label Addition - No labels were added (no valid labels found in agent output). - ` - ) - .write(); - return; - } - core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${itemNumber}: ${JSON.stringify(uniqueLabels)}`); + (async () => { try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - labels: uniqueLabels, - }); - core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${itemNumber}`); - core.setOutput("labels_added", uniqueLabels.join("\n")); - const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); - await core.summary - .addRaw( - ` - ## Label Addition - Successfully added ${uniqueLabels.length} label(s) to ${contextType} #${itemNumber}: - ${labelsListMarkdown} - ` - ) - .write(); + await main(); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to add labels: ${errorMessage}`); - core.setFailed(`Failed to add labels: ${errorMessage}`); + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); diff --git a/.github/workflows/smoke-copilot-playwright.lock.yml b/.github/workflows/smoke-copilot-playwright.lock.yml index 36ba1db97a..afb873f20e 100644 --- a/.github/workflows/smoke-copilot-playwright.lock.yml +++ b/.github/workflows/smoke-copilot-playwright.lock.yml @@ -8428,12 +8428,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} - add_labels_labels_added: ${{ steps.add_labels.outputs.labels_added }} - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -8446,2383 +8450,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash - run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/safe_output_helpers.cjs << 'EOF_80a143d8' - // @ts-check - /// - - /** - * Shared helper functions for safe-output scripts - * Provides common validation and target resolution logic - */ - - /** - * Parse a comma-separated list of allowed items from environment variable - * @param {string|undefined} envValue - Environment variable value - * @returns {string[]|undefined} Array of allowed items, or undefined if no restrictions - */ - function parseAllowedItems(envValue) { - const trimmed = envValue?.trim(); - if (!trimmed) { - return undefined; - } - return trimmed - .split(",") - .map(item => item.trim()) - .filter(item => item); - } - - /** - * Parse and validate max count from environment variable - * @param {string|undefined} envValue - Environment variable value - * @param {number} defaultValue - Default value if not specified - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function parseMaxCount(envValue, defaultValue = 3) { - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - /** - * Resolve the target number (issue/PR) based on configuration and context - * @param {Object} params - Resolution parameters - * @param {string} params.targetConfig - Target configuration ("triggering", "*", or explicit number) - * @param {any} params.item - Safe output item with optional item_number or pull_request_number - * @param {any} params.context - GitHub Actions context - * @param {string} params.itemType - Type of item being processed (for error messages) - * @param {boolean} params.supportsPR - Whether this safe output supports PR context - * @returns {{success: true, number: number, contextType: string} | {success: false, error: string, shouldFail: boolean}} Resolution result - */ - function resolveTarget(params) { - const { targetConfig, item, context, itemType, supportsPR = false } = params; - - // Check context type - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - - // Default target is "triggering" - const target = targetConfig || "triggering"; - - // Validate context for triggering mode - if (target === "triggering") { - if (supportsPR) { - if (!isIssueContext && !isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in issue or pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } else { - if (!isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } - } - - // Resolve target number - let itemNumber; - let contextType; - - if (target === "*") { - // Use item_number, issue_number, or pull_request_number from item - const numberField = supportsPR ? item.item_number || item.issue_number || item.pull_request_number : item.pull_request_number; - - if (numberField) { - itemNumber = typeof numberField === "number" ? numberField : parseInt(String(numberField), 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "item_number/issue_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, - shouldFail: true, - }; - } - contextType = supportsPR && (item.item_number || item.issue_number) ? "issue" : "pull request"; - } else { - return { - success: false, - error: `Target is "*" but no ${supportsPR ? "item_number/issue_number" : "pull_request_number"} specified in ${itemType} item`, - shouldFail: true, - }; - } - } else if (target !== "triggering") { - // Explicit number - itemNumber = parseInt(target, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "issue" : "pull request"} number in target configuration: ${target}`, - shouldFail: true, - }; - } - contextType = supportsPR ? "issue" : "pull request"; - } else { - // Use triggering context - if (isIssueContext) { - if (context.payload.issue) { - itemNumber = context.payload.issue.number; - contextType = "issue"; - } else { - return { - success: false, - error: "Issue context detected but no issue found in payload", - shouldFail: true, - }; - } - } else if (isPRContext) { - if (context.payload.pull_request) { - itemNumber = context.payload.pull_request.number; - contextType = "pull request"; - } else { - return { - success: false, - error: "Pull request context detected but no pull request found in payload", - shouldFail: true, - }; - } - } - } - - if (!itemNumber) { - return { - success: false, - error: `Could not determine ${supportsPR ? "issue or pull request" : "pull request"} number`, - shouldFail: true, - }; - } - - return { - success: true, - number: itemNumber, - contextType: contextType || (supportsPR ? "issue" : "pull request"), - }; - } - - module.exports = { - parseAllowedItems, - parseMaxCount, - resolveTarget, - }; - - EOF_80a143d8 - cat > /tmp/gh-aw/scripts/safe_output_processor.cjs << 'EOF_8f3864e2' - // @ts-check - /// - - /** - * Shared processor for safe-output scripts - * Provides common pipeline: load agent output, handle staged mode, parse config, resolve target - */ - - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { parseAllowedItems, resolveTarget } = require('/tmp/gh-aw/scripts/safe_output_helpers.cjs'); - const { getSafeOutputConfig, validateMaxCount } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs'); - - /** - * @typedef {Object} ProcessorConfig - * @property {string} itemType - The type field value to match in agent output (e.g., "add_labels") - * @property {string} configKey - The key to use when reading from config.json (e.g., "add_labels") - * @property {string} displayName - Human-readable name for logging (e.g., "Add Labels") - * @property {string} itemTypeName - Name used in error messages (e.g., "label addition") - * @property {boolean} [supportsPR] - When true, allows both issue AND PR contexts; when false, only PR context (default: false) - * @property {boolean} [supportsIssue] - When true, passes supportsPR=true to resolveTarget to enable both contexts (default: false) - * @property {boolean} [findMultiple] - Whether to find multiple items instead of just one (default: false) - * @property {Object} envVars - Environment variable names - * @property {string} [envVars.allowed] - Env var for allowed items list - * @property {string} [envVars.maxCount] - Env var for max count - * @property {string} [envVars.target] - Env var for target configuration - */ - - /** - * @typedef {Object} ProcessorResult - * @property {boolean} success - Whether processing should continue - * @property {any} [item] - The found item (when findMultiple is false) - * @property {any[]} [items] - The found items (when findMultiple is true) - * @property {Object} [config] - Parsed configuration - * @property {string[]|undefined} [config.allowed] - Allowed items list - * @property {number} [config.maxCount] - Maximum count - * @property {string} [config.target] - Target configuration - * @property {Object} [targetResult] - Result from resolveTarget (when findMultiple is false) - * @property {number} [targetResult.number] - Target issue/PR number - * @property {string} [targetResult.contextType] - Type of context (issue or pull request) - * @property {string} [reason] - Reason why processing should not continue - */ - - /** - * Process the initial steps common to safe-output scripts: - * 1. Load agent output - * 2. Find matching item(s) - * 3. Handle staged mode - * 4. Parse configuration - * 5. Resolve target (for single-item processors) - * - * @param {ProcessorConfig} config - Processor configuration - * @param {Object} stagedPreviewOptions - Options for staged preview - * @param {string} stagedPreviewOptions.title - Title for staged preview - * @param {string} stagedPreviewOptions.description - Description for staged preview - * @param {(item: any, index: number) => string} stagedPreviewOptions.renderItem - Function to render item in preview - * @returns {Promise} Processing result - */ - async function processSafeOutput(config, stagedPreviewOptions) { - const { itemType, configKey, displayName, itemTypeName, supportsPR = false, supportsIssue = false, findMultiple = false, envVars } = config; - - // Step 1: Load agent output - const result = loadAgentOutput(); - if (!result.success) { - return { success: false, reason: "Agent output not available" }; - } - - // Step 2: Find matching item(s) - let items; - if (findMultiple) { - items = result.items.filter(item => item.type === itemType); - if (items.length === 0) { - core.info(`No ${itemType} items found in agent output`); - return { success: false, reason: `No ${itemType} items found` }; - } - core.info(`Found ${items.length} ${itemType} item(s)`); - } else { - const item = result.items.find(item => item.type === itemType); - if (!item) { - core.warning(`No ${itemType.replace(/_/g, "-")} item found in agent output`); - return { success: false, reason: `No ${itemType} item found` }; - } - items = [item]; - // Log item details based on common fields - const itemDetails = getItemDetails(item); - if (itemDetails) { - core.info(`Found ${itemType.replace(/_/g, "-")} item with ${itemDetails}`); - } - } - - // Step 3: Handle staged mode - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - await generateStagedPreview({ - title: stagedPreviewOptions.title, - description: stagedPreviewOptions.description, - items: items, - renderItem: stagedPreviewOptions.renderItem, - }); - return { success: false, reason: "Staged mode - preview generated" }; - } - - // Step 4: Parse configuration - const safeOutputConfig = getSafeOutputConfig(configKey); - - // Parse allowed items (from env or config) - const allowedEnvValue = envVars.allowed ? process.env[envVars.allowed] : undefined; - const allowed = parseAllowedItems(allowedEnvValue) || safeOutputConfig.allowed; - if (allowed) { - core.info(`Allowed ${itemTypeName}s: ${JSON.stringify(allowed)}`); - } else { - core.info(`No ${itemTypeName} restrictions - any ${itemTypeName}s are allowed`); - } - - // Parse max count (env takes priority, then config) - const maxCountEnvValue = envVars.maxCount ? process.env[envVars.maxCount] : undefined; - const maxCountResult = validateMaxCount(maxCountEnvValue, safeOutputConfig.max); - if (!maxCountResult.valid) { - core.setFailed(maxCountResult.error); - return { success: false, reason: "Invalid max count configuration" }; - } - const maxCount = maxCountResult.value; - core.info(`Max count: ${maxCount}`); - - // Get target configuration - const target = envVars.target ? process.env[envVars.target] || "triggering" : "triggering"; - core.info(`${displayName} target configuration: ${target}`); - - // For multiple items, return early without target resolution - if (findMultiple) { - return { - success: true, - items: items, - config: { - allowed, - maxCount, - target, - }, - }; - } - - // Step 5: Resolve target (for single-item processors) - const item = items[0]; - const targetResult = resolveTarget({ - targetConfig: target, - item: item, - context, - itemType: itemTypeName, - // supportsPR in resolveTarget: true=both issue and PR contexts, false=PR-only - // If supportsIssue is true, we pass supportsPR=true to enable both contexts - supportsPR: supportsPR || supportsIssue, - }); - - if (!targetResult.success) { - if (targetResult.shouldFail) { - core.setFailed(targetResult.error); - } else { - core.info(targetResult.error); - } - return { success: false, reason: targetResult.error }; - } - - return { - success: true, - item: item, - config: { - allowed, - maxCount, - target, - }, - targetResult: { - number: targetResult.number, - contextType: targetResult.contextType, - }, - }; - } - - /** - * Get a description of item details for logging - * @param {any} item - The safe output item - * @returns {string|null} Description string or null - */ - function getItemDetails(item) { - if (item.labels && Array.isArray(item.labels)) { - return `${item.labels.length} labels`; - } - if (item.reviewers && Array.isArray(item.reviewers)) { - return `${item.reviewers.length} reviewers`; - } - return null; - } - - /** - * Sanitize and deduplicate an array of string items - * @param {any[]} items - Raw items array - * @returns {string[]} Sanitized and deduplicated array - */ - function sanitizeItems(items) { - return items - .filter(item => item != null && item !== false && item !== 0) - .map(item => String(item).trim()) - .filter(item => item) - .filter((item, index, arr) => arr.indexOf(item) === index); - } - - /** - * Filter items by allowed list - * @param {string[]} items - Items to filter - * @param {string[]|undefined} allowed - Allowed items list (undefined means all allowed) - * @returns {string[]} Filtered items - */ - function filterByAllowed(items, allowed) { - if (!allowed || allowed.length === 0) { - return items; - } - return items.filter(item => allowed.includes(item)); - } - - /** - * Limit items to max count - * @param {string[]} items - Items to limit - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Limited items - */ - function limitToMaxCount(items, maxCount) { - if (items.length > maxCount) { - core.info(`Too many items (${items.length}), limiting to ${maxCount}`); - return items.slice(0, maxCount); - } - return items; - } - - /** - * Process items through the standard pipeline: filter by allowed, sanitize, dedupe, limit - * @param {any[]} rawItems - Raw items array from agent output - * @param {string[]|undefined} allowed - Allowed items list - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Processed items - */ - function processItems(rawItems, allowed, maxCount) { - // Filter by allowed list first - const filtered = filterByAllowed(rawItems, allowed); - - // Sanitize and deduplicate - const sanitized = sanitizeItems(filtered); - - // Limit to max count - return limitToMaxCount(sanitized, maxCount); - } - - module.exports = { - processSafeOutput, - sanitizeItems, - filterByAllowed, - limitToMaxCount, - processItems, - }; - - EOF_8f3864e2 - cat > /tmp/gh-aw/scripts/safe_output_validator.cjs << 'EOF_437e6b4f' - // @ts-check - /// - - const fs = require("fs"); - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - - /** - * Load and parse the safe outputs configuration from config.json - * @returns {object} The parsed configuration object - */ - function loadSafeOutputsConfig() { - const configPath = "/tmp/gh-aw/safeoutputs/config.json"; - try { - if (!fs.existsSync(configPath)) { - core.warning(`Config file not found at ${configPath}, using defaults`); - return {}; - } - const configContent = fs.readFileSync(configPath, "utf8"); - return JSON.parse(configContent); - } catch (error) { - core.warning(`Failed to load config: ${error instanceof Error ? error.message : String(error)}`); - return {}; - } - } - - /** - * Get configuration for a specific safe output type - * @param {string} outputType - The type of safe output (e.g., "add_labels", "update_issue") - * @returns {{max?: number, target?: string, allowed?: string[]}} The configuration for this output type - */ - function getSafeOutputConfig(outputType) { - const config = loadSafeOutputsConfig(); - return config[outputType] || {}; - } - - /** - * Validate and sanitize a title string - * @param {any} title - The title to validate - * @param {string} fieldName - The name of the field for error messages (default: "title") - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateTitle(title, fieldName = "title") { - if (title === undefined || title === null) { - return { valid: false, error: `${fieldName} is required` }; - } - - if (typeof title !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - const trimmed = title.trim(); - if (trimmed.length === 0) { - return { valid: false, error: `${fieldName} cannot be empty` }; - } - - return { valid: true, value: trimmed }; - } - - /** - * Validate and sanitize a body/content string - * @param {any} body - The body to validate - * @param {string} fieldName - The name of the field for error messages (default: "body") - * @param {boolean} required - Whether the body is required (default: false) - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateBody(body, fieldName = "body", required = false) { - if (body === undefined || body === null) { - if (required) { - return { valid: false, error: `${fieldName} is required` }; - } - return { valid: true, value: "" }; - } - - if (typeof body !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - return { valid: true, value: body }; - } - - /** - * Validate and sanitize an array of labels - * @param {any} labels - The labels to validate - * @param {string[]|undefined} allowedLabels - Optional list of allowed labels - * @param {number} maxCount - Maximum number of labels allowed - * @returns {{valid: boolean, value?: string[], error?: string}} Validation result - */ - function validateLabels(labels, allowedLabels = undefined, maxCount = 3) { - if (!labels || !Array.isArray(labels)) { - return { valid: false, error: "labels must be an array" }; - } - - // Check for removal attempts (labels starting with '-') - for (const label of labels) { - if (label && typeof label === "string" && label.startsWith("-")) { - return { valid: false, error: `Label removal is not permitted. Found line starting with '-': ${label}` }; - } - } - - // Filter labels based on allowed list if provided - let validLabels = labels; - if (allowedLabels && allowedLabels.length > 0) { - validLabels = labels.filter(label => allowedLabels.includes(label)); - } - - // Sanitize and deduplicate labels - const uniqueLabels = validLabels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - - // Apply max count limit - if (uniqueLabels.length > maxCount) { - core.info(`Too many labels (${uniqueLabels.length}), limiting to ${maxCount}`); - return { valid: true, value: uniqueLabels.slice(0, maxCount) }; - } - - if (uniqueLabels.length === 0) { - return { valid: false, error: "No valid labels found after sanitization" }; - } - - return { valid: true, value: uniqueLabels }; - } - - /** - * Validate max count from environment variable with config fallback - * @param {string|undefined} envValue - Environment variable value - * @param {number|undefined} configDefault - Default from config.json - * @param {number} [fallbackDefault] - Fallback default for testing (optional, defaults to 1) - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function validateMaxCount(envValue, configDefault, fallbackDefault = 1) { - // Priority: env var > config.json > fallback default - // In production, config.json should always have the default - // Fallback is provided for backward compatibility and testing - const defaultValue = configDefault !== undefined ? configDefault : fallbackDefault; - - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - module.exports = { - loadSafeOutputsConfig, - getSafeOutputConfig, - validateTitle, - validateBody, - validateLabels, - validateMaxCount, - }; - - EOF_437e6b4f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_ISSUE_EXPIRES: "1" - GH_AW_WORKFLOW_NAME: "Smoke Copilot Playwright" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 📰 *BREAKING: Report filed by [{workflow_name}]({run_url})*\",\"runStarted\":\"📰 BREAKING: [{workflow_name}]({run_url}) is now investigating this {event_type}. Sources say the story is developing...\",\"runSuccess\":\"📰 VERDICT: [{workflow_name}]({run_url}) has concluded. All systems operational. This is a developing story. 🎤\",\"runFailure\":\"📰 DEVELOPING STORY: [{workflow_name}]({run_url}) reports {status}. Our correspondents are investigating the incident...\"}" - with: +GH_AW_ISSUE_EXPIRES=1 with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; + summaryContent += `**Repository:** ${item.repo}\n\n`; } if (item.body) { - content += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; - } - return content; - }, - }); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } - } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); + summaryContent += "---\n\n"; } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; - } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; } await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); + return; } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); - } - (async () => { - await main(); - })(); - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_HIDE_OLDER_COMMENTS: "true" - GH_AW_CREATED_ISSUE_URL: ${{ steps.create_issue.outputs.issue_url }} - GH_AW_CREATED_ISSUE_NUMBER: ${{ steps.create_issue.outputs.issue_number }} - GH_AW_TEMPORARY_ID_MAP: ${{ steps.create_issue.outputs.temporary_id_map }} - GH_AW_WORKFLOW_NAME: "Smoke Copilot Playwright" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 📰 *BREAKING: Report filed by [{workflow_name}]({run_url})*\",\"runStarted\":\"📰 BREAKING: [{workflow_name}]({run_url}) is now investigating this {event_type}. Sources say the story is developing...\",\"runSuccess\":\"📰 VERDICT: [{workflow_name}]({run_url}) has concluded. All systems operational. This is a developing story. 🎤\",\"runFailure\":\"📰 DEVELOPING STORY: [{workflow_name}]({run_url}) reports {status}. Our correspondents are investigating the incident...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); } - page++; } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); - } - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; - } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; - } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); - return; - } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; - } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; - } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; - } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); - } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); - } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); - } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); - - name: Add Labels - id: add_labels - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_labels')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_LABELS_ALLOWED: "smoke-copilot" - GH_AW_WORKFLOW_NAME: "Smoke Copilot Playwright" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 📰 *BREAKING: Report filed by [{workflow_name}]({run_url})*\",\"runStarted\":\"📰 BREAKING: [{workflow_name}]({run_url}) is now investigating this {event_type}. Sources say the story is developing...\",\"runSuccess\":\"📰 VERDICT: [{workflow_name}]({run_url}) has concluded. All systems operational. This is a developing story. 🎤\",\"runFailure\":\"📰 DEVELOPING STORY: [{workflow_name}]({run_url}) reports {status}. Our correspondents are investigating the incident...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { processSafeOutput } = require('/tmp/gh-aw/scripts/safe_output_processor.cjs'); - const { validateLabels } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs'); - async function main() { - const result = await processSafeOutput( - { - itemType: "add_labels", - configKey: "add_labels", - displayName: "Labels", - itemTypeName: "label addition", - supportsPR: true, - supportsIssue: true, - envVars: { - allowed: "GH_AW_LABELS_ALLOWED", - maxCount: "GH_AW_LABELS_MAX_COUNT", - target: "GH_AW_LABELS_TARGET", - }, - }, - { - title: "Add Labels", - description: "The following labels would be added if staged mode was disabled:", - renderItem: item => { - let content = ""; - if (item.item_number) { - content += `**Target Issue:** #${item.item_number}\n\n`; - } else { - content += `**Target:** Current issue/PR\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels to add:** ${item.labels.join(", ")}\n\n`; - } - return content; - }, - } - ); - if (!result.success) { - return; - } - const { item: labelsItem, config, targetResult } = result; - if (!config || !targetResult || targetResult.number === undefined) { - core.setFailed("Internal error: config, targetResult, or targetResult.number is undefined"); - return; - } - const { allowed: allowedLabels, maxCount } = config; - const itemNumber = targetResult.number; - const { contextType } = targetResult; - const requestedLabels = labelsItem.labels || []; - core.info(`Requested labels: ${JSON.stringify(requestedLabels)}`); - const labelsResult = validateLabels(requestedLabels, allowedLabels, maxCount); - if (!labelsResult.valid) { - if (labelsResult.error && labelsResult.error.includes("No valid labels")) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` - ## Label Addition - No labels were added (no valid labels found in agent output). - ` - ) - .write(); - return; - } - core.setFailed(labelsResult.error || "Invalid labels"); - return; - } - const uniqueLabels = labelsResult.value || []; - if (uniqueLabels.length === 0) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` - ## Label Addition - No labels were added (no valid labels found in agent output). - ` - ) - .write(); - return; - } - core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${itemNumber}: ${JSON.stringify(uniqueLabels)}`); + (async () => { try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - labels: uniqueLabels, - }); - core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${itemNumber}`); - core.setOutput("labels_added", uniqueLabels.join("\n")); - const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); - await core.summary - .addRaw( - ` - ## Label Addition - Successfully added ${uniqueLabels.length} label(s) to ${contextType} #${itemNumber}: - ${labelsListMarkdown} - ` - ) - .write(); + await main(); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to add labels: ${errorMessage}`); - core.setFailed(`Failed to add labels: ${errorMessage}`); + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/smoke-copilot-safe-inputs.lock.yml b/.github/workflows/smoke-copilot-safe-inputs.lock.yml index f12b29db9a..94bec23619 100644 --- a/.github/workflows/smoke-copilot-safe-inputs.lock.yml +++ b/.github/workflows/smoke-copilot-safe-inputs.lock.yml @@ -8295,9 +8295,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} - add_labels_labels_added: ${{ steps.add_labels.outputs.labels_added }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -8310,1811 +8317,175 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/safe_output_helpers.cjs << 'EOF_80a143d8' - // @ts-check - /// - - /** - * Shared helper functions for safe-output scripts - * Provides common validation and target resolution logic - */ - - /** - * Parse a comma-separated list of allowed items from environment variable - * @param {string|undefined} envValue - Environment variable value - * @returns {string[]|undefined} Array of allowed items, or undefined if no restrictions - */ - function parseAllowedItems(envValue) { - const trimmed = envValue?.trim(); - if (!trimmed) { - return undefined; - } - return trimmed - .split(",") - .map(item => item.trim()) - .filter(item => item); - } - - /** - * Parse and validate max count from environment variable - * @param {string|undefined} envValue - Environment variable value - * @param {number} defaultValue - Default value if not specified - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function parseMaxCount(envValue, defaultValue = 3) { - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - /** - * Resolve the target number (issue/PR) based on configuration and context - * @param {Object} params - Resolution parameters - * @param {string} params.targetConfig - Target configuration ("triggering", "*", or explicit number) - * @param {any} params.item - Safe output item with optional item_number or pull_request_number - * @param {any} params.context - GitHub Actions context - * @param {string} params.itemType - Type of item being processed (for error messages) - * @param {boolean} params.supportsPR - Whether this safe output supports PR context - * @returns {{success: true, number: number, contextType: string} | {success: false, error: string, shouldFail: boolean}} Resolution result - */ - function resolveTarget(params) { - const { targetConfig, item, context, itemType, supportsPR = false } = params; - - // Check context type - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - - // Default target is "triggering" - const target = targetConfig || "triggering"; - - // Validate context for triggering mode - if (target === "triggering") { - if (supportsPR) { - if (!isIssueContext && !isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in issue or pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } else { - if (!isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } - } - - // Resolve target number - let itemNumber; - let contextType; - - if (target === "*") { - // Use item_number, issue_number, or pull_request_number from item - const numberField = supportsPR ? item.item_number || item.issue_number || item.pull_request_number : item.pull_request_number; - - if (numberField) { - itemNumber = typeof numberField === "number" ? numberField : parseInt(String(numberField), 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "item_number/issue_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, - shouldFail: true, - }; - } - contextType = supportsPR && (item.item_number || item.issue_number) ? "issue" : "pull request"; - } else { - return { - success: false, - error: `Target is "*" but no ${supportsPR ? "item_number/issue_number" : "pull_request_number"} specified in ${itemType} item`, - shouldFail: true, - }; - } - } else if (target !== "triggering") { - // Explicit number - itemNumber = parseInt(target, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "issue" : "pull request"} number in target configuration: ${target}`, - shouldFail: true, - }; - } - contextType = supportsPR ? "issue" : "pull request"; - } else { - // Use triggering context - if (isIssueContext) { - if (context.payload.issue) { - itemNumber = context.payload.issue.number; - contextType = "issue"; - } else { - return { - success: false, - error: "Issue context detected but no issue found in payload", - shouldFail: true, - }; - } - } else if (isPRContext) { - if (context.payload.pull_request) { - itemNumber = context.payload.pull_request.number; - contextType = "pull request"; - } else { - return { - success: false, - error: "Pull request context detected but no pull request found in payload", - shouldFail: true, - }; - } - } - } - - if (!itemNumber) { - return { - success: false, - error: `Could not determine ${supportsPR ? "issue or pull request" : "pull request"} number`, - shouldFail: true, - }; - } - - return { - success: true, - number: itemNumber, - contextType: contextType || (supportsPR ? "issue" : "pull request"), - }; - } - - module.exports = { - parseAllowedItems, - parseMaxCount, - resolveTarget, - }; - - EOF_80a143d8 - cat > /tmp/gh-aw/scripts/safe_output_processor.cjs << 'EOF_8f3864e2' - // @ts-check - /// - - /** - * Shared processor for safe-output scripts - * Provides common pipeline: load agent output, handle staged mode, parse config, resolve target - */ - - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { parseAllowedItems, resolveTarget } = require('/tmp/gh-aw/scripts/safe_output_helpers.cjs'); - const { getSafeOutputConfig, validateMaxCount } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs'); - - /** - * @typedef {Object} ProcessorConfig - * @property {string} itemType - The type field value to match in agent output (e.g., "add_labels") - * @property {string} configKey - The key to use when reading from config.json (e.g., "add_labels") - * @property {string} displayName - Human-readable name for logging (e.g., "Add Labels") - * @property {string} itemTypeName - Name used in error messages (e.g., "label addition") - * @property {boolean} [supportsPR] - When true, allows both issue AND PR contexts; when false, only PR context (default: false) - * @property {boolean} [supportsIssue] - When true, passes supportsPR=true to resolveTarget to enable both contexts (default: false) - * @property {boolean} [findMultiple] - Whether to find multiple items instead of just one (default: false) - * @property {Object} envVars - Environment variable names - * @property {string} [envVars.allowed] - Env var for allowed items list - * @property {string} [envVars.maxCount] - Env var for max count - * @property {string} [envVars.target] - Env var for target configuration - */ - - /** - * @typedef {Object} ProcessorResult - * @property {boolean} success - Whether processing should continue - * @property {any} [item] - The found item (when findMultiple is false) - * @property {any[]} [items] - The found items (when findMultiple is true) - * @property {Object} [config] - Parsed configuration - * @property {string[]|undefined} [config.allowed] - Allowed items list - * @property {number} [config.maxCount] - Maximum count - * @property {string} [config.target] - Target configuration - * @property {Object} [targetResult] - Result from resolveTarget (when findMultiple is false) - * @property {number} [targetResult.number] - Target issue/PR number - * @property {string} [targetResult.contextType] - Type of context (issue or pull request) - * @property {string} [reason] - Reason why processing should not continue - */ - - /** - * Process the initial steps common to safe-output scripts: - * 1. Load agent output - * 2. Find matching item(s) - * 3. Handle staged mode - * 4. Parse configuration - * 5. Resolve target (for single-item processors) - * - * @param {ProcessorConfig} config - Processor configuration - * @param {Object} stagedPreviewOptions - Options for staged preview - * @param {string} stagedPreviewOptions.title - Title for staged preview - * @param {string} stagedPreviewOptions.description - Description for staged preview - * @param {(item: any, index: number) => string} stagedPreviewOptions.renderItem - Function to render item in preview - * @returns {Promise} Processing result - */ - async function processSafeOutput(config, stagedPreviewOptions) { - const { itemType, configKey, displayName, itemTypeName, supportsPR = false, supportsIssue = false, findMultiple = false, envVars } = config; - - // Step 1: Load agent output - const result = loadAgentOutput(); - if (!result.success) { - return { success: false, reason: "Agent output not available" }; - } - - // Step 2: Find matching item(s) - let items; - if (findMultiple) { - items = result.items.filter(item => item.type === itemType); - if (items.length === 0) { - core.info(`No ${itemType} items found in agent output`); - return { success: false, reason: `No ${itemType} items found` }; - } - core.info(`Found ${items.length} ${itemType} item(s)`); - } else { - const item = result.items.find(item => item.type === itemType); - if (!item) { - core.warning(`No ${itemType.replace(/_/g, "-")} item found in agent output`); - return { success: false, reason: `No ${itemType} item found` }; - } - items = [item]; - // Log item details based on common fields - const itemDetails = getItemDetails(item); - if (itemDetails) { - core.info(`Found ${itemType.replace(/_/g, "-")} item with ${itemDetails}`); - } - } - - // Step 3: Handle staged mode - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - await generateStagedPreview({ - title: stagedPreviewOptions.title, - description: stagedPreviewOptions.description, - items: items, - renderItem: stagedPreviewOptions.renderItem, - }); - return { success: false, reason: "Staged mode - preview generated" }; - } - - // Step 4: Parse configuration - const safeOutputConfig = getSafeOutputConfig(configKey); - - // Parse allowed items (from env or config) - const allowedEnvValue = envVars.allowed ? process.env[envVars.allowed] : undefined; - const allowed = parseAllowedItems(allowedEnvValue) || safeOutputConfig.allowed; - if (allowed) { - core.info(`Allowed ${itemTypeName}s: ${JSON.stringify(allowed)}`); - } else { - core.info(`No ${itemTypeName} restrictions - any ${itemTypeName}s are allowed`); - } - - // Parse max count (env takes priority, then config) - const maxCountEnvValue = envVars.maxCount ? process.env[envVars.maxCount] : undefined; - const maxCountResult = validateMaxCount(maxCountEnvValue, safeOutputConfig.max); - if (!maxCountResult.valid) { - core.setFailed(maxCountResult.error); - return { success: false, reason: "Invalid max count configuration" }; - } - const maxCount = maxCountResult.value; - core.info(`Max count: ${maxCount}`); - - // Get target configuration - const target = envVars.target ? process.env[envVars.target] || "triggering" : "triggering"; - core.info(`${displayName} target configuration: ${target}`); - - // For multiple items, return early without target resolution - if (findMultiple) { - return { - success: true, - items: items, - config: { - allowed, - maxCount, - target, - }, - }; - } - - // Step 5: Resolve target (for single-item processors) - const item = items[0]; - const targetResult = resolveTarget({ - targetConfig: target, - item: item, - context, - itemType: itemTypeName, - // supportsPR in resolveTarget: true=both issue and PR contexts, false=PR-only - // If supportsIssue is true, we pass supportsPR=true to enable both contexts - supportsPR: supportsPR || supportsIssue, - }); - - if (!targetResult.success) { - if (targetResult.shouldFail) { - core.setFailed(targetResult.error); - } else { - core.info(targetResult.error); - } - return { success: false, reason: targetResult.error }; - } - - return { - success: true, - item: item, - config: { - allowed, - maxCount, - target, - }, - targetResult: { - number: targetResult.number, - contextType: targetResult.contextType, - }, - }; - } - - /** - * Get a description of item details for logging - * @param {any} item - The safe output item - * @returns {string|null} Description string or null - */ - function getItemDetails(item) { - if (item.labels && Array.isArray(item.labels)) { - return `${item.labels.length} labels`; - } - if (item.reviewers && Array.isArray(item.reviewers)) { - return `${item.reviewers.length} reviewers`; - } - return null; - } - - /** - * Sanitize and deduplicate an array of string items - * @param {any[]} items - Raw items array - * @returns {string[]} Sanitized and deduplicated array - */ - function sanitizeItems(items) { - return items - .filter(item => item != null && item !== false && item !== 0) - .map(item => String(item).trim()) - .filter(item => item) - .filter((item, index, arr) => arr.indexOf(item) === index); - } - - /** - * Filter items by allowed list - * @param {string[]} items - Items to filter - * @param {string[]|undefined} allowed - Allowed items list (undefined means all allowed) - * @returns {string[]} Filtered items - */ - function filterByAllowed(items, allowed) { - if (!allowed || allowed.length === 0) { - return items; - } - return items.filter(item => allowed.includes(item)); - } - - /** - * Limit items to max count - * @param {string[]} items - Items to limit - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Limited items - */ - function limitToMaxCount(items, maxCount) { - if (items.length > maxCount) { - core.info(`Too many items (${items.length}), limiting to ${maxCount}`); - return items.slice(0, maxCount); - } - return items; - } - - /** - * Process items through the standard pipeline: filter by allowed, sanitize, dedupe, limit - * @param {any[]} rawItems - Raw items array from agent output - * @param {string[]|undefined} allowed - Allowed items list - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Processed items - */ - function processItems(rawItems, allowed, maxCount) { - // Filter by allowed list first - const filtered = filterByAllowed(rawItems, allowed); - - // Sanitize and deduplicate - const sanitized = sanitizeItems(filtered); - - // Limit to max count - return limitToMaxCount(sanitized, maxCount); - } - - module.exports = { - processSafeOutput, - sanitizeItems, - filterByAllowed, - limitToMaxCount, - processItems, - }; - - EOF_8f3864e2 - cat > /tmp/gh-aw/scripts/safe_output_validator.cjs << 'EOF_437e6b4f' - // @ts-check - /// - - const fs = require("fs"); - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - - /** - * Load and parse the safe outputs configuration from config.json - * @returns {object} The parsed configuration object - */ - function loadSafeOutputsConfig() { - const configPath = "/tmp/gh-aw/safeoutputs/config.json"; - try { - if (!fs.existsSync(configPath)) { - core.warning(`Config file not found at ${configPath}, using defaults`); - return {}; - } - const configContent = fs.readFileSync(configPath, "utf8"); - return JSON.parse(configContent); - } catch (error) { - core.warning(`Failed to load config: ${error instanceof Error ? error.message : String(error)}`); - return {}; - } - } - - /** - * Get configuration for a specific safe output type - * @param {string} outputType - The type of safe output (e.g., "add_labels", "update_issue") - * @returns {{max?: number, target?: string, allowed?: string[]}} The configuration for this output type - */ - function getSafeOutputConfig(outputType) { - const config = loadSafeOutputsConfig(); - return config[outputType] || {}; - } - - /** - * Validate and sanitize a title string - * @param {any} title - The title to validate - * @param {string} fieldName - The name of the field for error messages (default: "title") - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateTitle(title, fieldName = "title") { - if (title === undefined || title === null) { - return { valid: false, error: `${fieldName} is required` }; - } - - if (typeof title !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - const trimmed = title.trim(); - if (trimmed.length === 0) { - return { valid: false, error: `${fieldName} cannot be empty` }; - } - - return { valid: true, value: trimmed }; - } - - /** - * Validate and sanitize a body/content string - * @param {any} body - The body to validate - * @param {string} fieldName - The name of the field for error messages (default: "body") - * @param {boolean} required - Whether the body is required (default: false) - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateBody(body, fieldName = "body", required = false) { - if (body === undefined || body === null) { - if (required) { - return { valid: false, error: `${fieldName} is required` }; - } - return { valid: true, value: "" }; - } - - if (typeof body !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - return { valid: true, value: body }; - } - - /** - * Validate and sanitize an array of labels - * @param {any} labels - The labels to validate - * @param {string[]|undefined} allowedLabels - Optional list of allowed labels - * @param {number} maxCount - Maximum number of labels allowed - * @returns {{valid: boolean, value?: string[], error?: string}} Validation result - */ - function validateLabels(labels, allowedLabels = undefined, maxCount = 3) { - if (!labels || !Array.isArray(labels)) { - return { valid: false, error: "labels must be an array" }; - } - - // Check for removal attempts (labels starting with '-') - for (const label of labels) { - if (label && typeof label === "string" && label.startsWith("-")) { - return { valid: false, error: `Label removal is not permitted. Found line starting with '-': ${label}` }; - } - } - - // Filter labels based on allowed list if provided - let validLabels = labels; - if (allowedLabels && allowedLabels.length > 0) { - validLabels = labels.filter(label => allowedLabels.includes(label)); - } - - // Sanitize and deduplicate labels - const uniqueLabels = validLabels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - - // Apply max count limit - if (uniqueLabels.length > maxCount) { - core.info(`Too many labels (${uniqueLabels.length}), limiting to ${maxCount}`); - return { valid: true, value: uniqueLabels.slice(0, maxCount) }; - } - - if (uniqueLabels.length === 0) { - return { valid: false, error: "No valid labels found after sanitization" }; - } - - return { valid: true, value: uniqueLabels }; - } - - /** - * Validate max count from environment variable with config fallback - * @param {string|undefined} envValue - Environment variable value - * @param {number|undefined} configDefault - Default from config.json - * @param {number} [fallbackDefault] - Fallback default for testing (optional, defaults to 1) - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function validateMaxCount(envValue, configDefault, fallbackDefault = 1) { - // Priority: env var > config.json > fallback default - // In production, config.json should always have the default - // Fallback is provided for backward compatibility and testing - const defaultValue = configDefault !== undefined ? configDefault : fallbackDefault; - - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - module.exports = { - loadSafeOutputsConfig, - getSafeOutputConfig, - validateTitle, - validateBody, - validateLabels, - validateMaxCount, - }; - - EOF_437e6b4f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_HIDE_OLDER_COMMENTS: "true" - GH_AW_WORKFLOW_NAME: "Smoke Copilot Safe Inputs" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } - } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; - } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); - } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); - - name: Add Labels - id: add_labels - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_labels')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_LABELS_ALLOWED: "smoke-copilot" - GH_AW_WORKFLOW_NAME: "Smoke Copilot Safe Inputs" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { processSafeOutput } = require('/tmp/gh-aw/scripts/safe_output_processor.cjs'); - const { validateLabels } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs'); - async function main() { - const result = await processSafeOutput( - { - itemType: "add_labels", - configKey: "add_labels", - displayName: "Labels", - itemTypeName: "label addition", - supportsPR: true, - supportsIssue: true, - envVars: { - allowed: "GH_AW_LABELS_ALLOWED", - maxCount: "GH_AW_LABELS_MAX_COUNT", - target: "GH_AW_LABELS_TARGET", - }, - }, - { - title: "Add Labels", - description: "The following labels would be added if staged mode was disabled:", - renderItem: item => { - let content = ""; - if (item.item_number) { - content += `**Target Issue:** #${item.item_number}\n\n`; - } else { - content += `**Target:** Current issue/PR\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels to add:** ${item.labels.join(", ")}\n\n`; - } - return content; - }, - } - ); - if (!result.success) { - return; - } - const { item: labelsItem, config, targetResult } = result; - if (!config || !targetResult || targetResult.number === undefined) { - core.setFailed("Internal error: config, targetResult, or targetResult.number is undefined"); - return; - } - const { allowed: allowedLabels, maxCount } = config; - const itemNumber = targetResult.number; - const { contextType } = targetResult; - const requestedLabels = labelsItem.labels || []; - core.info(`Requested labels: ${JSON.stringify(requestedLabels)}`); - const labelsResult = validateLabels(requestedLabels, allowedLabels, maxCount); - if (!labelsResult.valid) { - if (labelsResult.error && labelsResult.error.includes("No valid labels")) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` - ## Label Addition - No labels were added (no valid labels found in agent output). - ` - ) - .write(); - return; - } - core.setFailed(labelsResult.error || "Invalid labels"); - return; - } - const uniqueLabels = labelsResult.value || []; - if (uniqueLabels.length === 0) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` - ## Label Addition - No labels were added (no valid labels found in agent output). - ` - ) - .write(); - return; - } - core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${itemNumber}: ${JSON.stringify(uniqueLabels)}`); + (async () => { try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - labels: uniqueLabels, - }); - core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${itemNumber}`); - core.setOutput("labels_added", uniqueLabels.join("\n")); - const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); - await core.summary - .addRaw( - ` - ## Label Addition - Successfully added ${uniqueLabels.length} label(s) to ${contextType} #${itemNumber}: - ${labelsListMarkdown} - ` - ) - .write(); + await main(); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to add labels: ${errorMessage}`); - core.setFailed(`Failed to add labels: ${errorMessage}`); + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index e0c05c6db0..532c381cf0 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -7050,12 +7050,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} - add_labels_labels_added: ${{ steps.add_labels.outputs.labels_added }} - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7068,2383 +7072,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash - run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/safe_output_helpers.cjs << 'EOF_80a143d8' - // @ts-check - /// - - /** - * Shared helper functions for safe-output scripts - * Provides common validation and target resolution logic - */ - - /** - * Parse a comma-separated list of allowed items from environment variable - * @param {string|undefined} envValue - Environment variable value - * @returns {string[]|undefined} Array of allowed items, or undefined if no restrictions - */ - function parseAllowedItems(envValue) { - const trimmed = envValue?.trim(); - if (!trimmed) { - return undefined; - } - return trimmed - .split(",") - .map(item => item.trim()) - .filter(item => item); - } - - /** - * Parse and validate max count from environment variable - * @param {string|undefined} envValue - Environment variable value - * @param {number} defaultValue - Default value if not specified - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function parseMaxCount(envValue, defaultValue = 3) { - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - /** - * Resolve the target number (issue/PR) based on configuration and context - * @param {Object} params - Resolution parameters - * @param {string} params.targetConfig - Target configuration ("triggering", "*", or explicit number) - * @param {any} params.item - Safe output item with optional item_number or pull_request_number - * @param {any} params.context - GitHub Actions context - * @param {string} params.itemType - Type of item being processed (for error messages) - * @param {boolean} params.supportsPR - Whether this safe output supports PR context - * @returns {{success: true, number: number, contextType: string} | {success: false, error: string, shouldFail: boolean}} Resolution result - */ - function resolveTarget(params) { - const { targetConfig, item, context, itemType, supportsPR = false } = params; - - // Check context type - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - - // Default target is "triggering" - const target = targetConfig || "triggering"; - - // Validate context for triggering mode - if (target === "triggering") { - if (supportsPR) { - if (!isIssueContext && !isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in issue or pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } else { - if (!isPRContext) { - return { - success: false, - error: `Target is "triggering" but not running in pull request context, skipping ${itemType}`, - shouldFail: false, // Just skip, don't fail the workflow - }; - } - } - } - - // Resolve target number - let itemNumber; - let contextType; - - if (target === "*") { - // Use item_number, issue_number, or pull_request_number from item - const numberField = supportsPR ? item.item_number || item.issue_number || item.pull_request_number : item.pull_request_number; - - if (numberField) { - itemNumber = typeof numberField === "number" ? numberField : parseInt(String(numberField), 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "item_number/issue_number/pull_request_number" : "pull_request_number"} specified: ${numberField}`, - shouldFail: true, - }; - } - contextType = supportsPR && (item.item_number || item.issue_number) ? "issue" : "pull request"; - } else { - return { - success: false, - error: `Target is "*" but no ${supportsPR ? "item_number/issue_number" : "pull_request_number"} specified in ${itemType} item`, - shouldFail: true, - }; - } - } else if (target !== "triggering") { - // Explicit number - itemNumber = parseInt(target, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - return { - success: false, - error: `Invalid ${supportsPR ? "issue" : "pull request"} number in target configuration: ${target}`, - shouldFail: true, - }; - } - contextType = supportsPR ? "issue" : "pull request"; - } else { - // Use triggering context - if (isIssueContext) { - if (context.payload.issue) { - itemNumber = context.payload.issue.number; - contextType = "issue"; - } else { - return { - success: false, - error: "Issue context detected but no issue found in payload", - shouldFail: true, - }; - } - } else if (isPRContext) { - if (context.payload.pull_request) { - itemNumber = context.payload.pull_request.number; - contextType = "pull request"; - } else { - return { - success: false, - error: "Pull request context detected but no pull request found in payload", - shouldFail: true, - }; - } - } - } - - if (!itemNumber) { - return { - success: false, - error: `Could not determine ${supportsPR ? "issue or pull request" : "pull request"} number`, - shouldFail: true, - }; - } - - return { - success: true, - number: itemNumber, - contextType: contextType || (supportsPR ? "issue" : "pull request"), - }; - } - - module.exports = { - parseAllowedItems, - parseMaxCount, - resolveTarget, - }; - - EOF_80a143d8 - cat > /tmp/gh-aw/scripts/safe_output_processor.cjs << 'EOF_8f3864e2' - // @ts-check - /// - - /** - * Shared processor for safe-output scripts - * Provides common pipeline: load agent output, handle staged mode, parse config, resolve target - */ - - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { parseAllowedItems, resolveTarget } = require('/tmp/gh-aw/scripts/safe_output_helpers.cjs'); - const { getSafeOutputConfig, validateMaxCount } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs'); - - /** - * @typedef {Object} ProcessorConfig - * @property {string} itemType - The type field value to match in agent output (e.g., "add_labels") - * @property {string} configKey - The key to use when reading from config.json (e.g., "add_labels") - * @property {string} displayName - Human-readable name for logging (e.g., "Add Labels") - * @property {string} itemTypeName - Name used in error messages (e.g., "label addition") - * @property {boolean} [supportsPR] - When true, allows both issue AND PR contexts; when false, only PR context (default: false) - * @property {boolean} [supportsIssue] - When true, passes supportsPR=true to resolveTarget to enable both contexts (default: false) - * @property {boolean} [findMultiple] - Whether to find multiple items instead of just one (default: false) - * @property {Object} envVars - Environment variable names - * @property {string} [envVars.allowed] - Env var for allowed items list - * @property {string} [envVars.maxCount] - Env var for max count - * @property {string} [envVars.target] - Env var for target configuration - */ - - /** - * @typedef {Object} ProcessorResult - * @property {boolean} success - Whether processing should continue - * @property {any} [item] - The found item (when findMultiple is false) - * @property {any[]} [items] - The found items (when findMultiple is true) - * @property {Object} [config] - Parsed configuration - * @property {string[]|undefined} [config.allowed] - Allowed items list - * @property {number} [config.maxCount] - Maximum count - * @property {string} [config.target] - Target configuration - * @property {Object} [targetResult] - Result from resolveTarget (when findMultiple is false) - * @property {number} [targetResult.number] - Target issue/PR number - * @property {string} [targetResult.contextType] - Type of context (issue or pull request) - * @property {string} [reason] - Reason why processing should not continue - */ - - /** - * Process the initial steps common to safe-output scripts: - * 1. Load agent output - * 2. Find matching item(s) - * 3. Handle staged mode - * 4. Parse configuration - * 5. Resolve target (for single-item processors) - * - * @param {ProcessorConfig} config - Processor configuration - * @param {Object} stagedPreviewOptions - Options for staged preview - * @param {string} stagedPreviewOptions.title - Title for staged preview - * @param {string} stagedPreviewOptions.description - Description for staged preview - * @param {(item: any, index: number) => string} stagedPreviewOptions.renderItem - Function to render item in preview - * @returns {Promise} Processing result - */ - async function processSafeOutput(config, stagedPreviewOptions) { - const { itemType, configKey, displayName, itemTypeName, supportsPR = false, supportsIssue = false, findMultiple = false, envVars } = config; - - // Step 1: Load agent output - const result = loadAgentOutput(); - if (!result.success) { - return { success: false, reason: "Agent output not available" }; - } - - // Step 2: Find matching item(s) - let items; - if (findMultiple) { - items = result.items.filter(item => item.type === itemType); - if (items.length === 0) { - core.info(`No ${itemType} items found in agent output`); - return { success: false, reason: `No ${itemType} items found` }; - } - core.info(`Found ${items.length} ${itemType} item(s)`); - } else { - const item = result.items.find(item => item.type === itemType); - if (!item) { - core.warning(`No ${itemType.replace(/_/g, "-")} item found in agent output`); - return { success: false, reason: `No ${itemType} item found` }; - } - items = [item]; - // Log item details based on common fields - const itemDetails = getItemDetails(item); - if (itemDetails) { - core.info(`Found ${itemType.replace(/_/g, "-")} item with ${itemDetails}`); - } - } - - // Step 3: Handle staged mode - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - await generateStagedPreview({ - title: stagedPreviewOptions.title, - description: stagedPreviewOptions.description, - items: items, - renderItem: stagedPreviewOptions.renderItem, - }); - return { success: false, reason: "Staged mode - preview generated" }; - } - - // Step 4: Parse configuration - const safeOutputConfig = getSafeOutputConfig(configKey); - - // Parse allowed items (from env or config) - const allowedEnvValue = envVars.allowed ? process.env[envVars.allowed] : undefined; - const allowed = parseAllowedItems(allowedEnvValue) || safeOutputConfig.allowed; - if (allowed) { - core.info(`Allowed ${itemTypeName}s: ${JSON.stringify(allowed)}`); - } else { - core.info(`No ${itemTypeName} restrictions - any ${itemTypeName}s are allowed`); - } - - // Parse max count (env takes priority, then config) - const maxCountEnvValue = envVars.maxCount ? process.env[envVars.maxCount] : undefined; - const maxCountResult = validateMaxCount(maxCountEnvValue, safeOutputConfig.max); - if (!maxCountResult.valid) { - core.setFailed(maxCountResult.error); - return { success: false, reason: "Invalid max count configuration" }; - } - const maxCount = maxCountResult.value; - core.info(`Max count: ${maxCount}`); - - // Get target configuration - const target = envVars.target ? process.env[envVars.target] || "triggering" : "triggering"; - core.info(`${displayName} target configuration: ${target}`); - - // For multiple items, return early without target resolution - if (findMultiple) { - return { - success: true, - items: items, - config: { - allowed, - maxCount, - target, - }, - }; - } - - // Step 5: Resolve target (for single-item processors) - const item = items[0]; - const targetResult = resolveTarget({ - targetConfig: target, - item: item, - context, - itemType: itemTypeName, - // supportsPR in resolveTarget: true=both issue and PR contexts, false=PR-only - // If supportsIssue is true, we pass supportsPR=true to enable both contexts - supportsPR: supportsPR || supportsIssue, - }); - - if (!targetResult.success) { - if (targetResult.shouldFail) { - core.setFailed(targetResult.error); - } else { - core.info(targetResult.error); - } - return { success: false, reason: targetResult.error }; - } - - return { - success: true, - item: item, - config: { - allowed, - maxCount, - target, - }, - targetResult: { - number: targetResult.number, - contextType: targetResult.contextType, - }, - }; - } - - /** - * Get a description of item details for logging - * @param {any} item - The safe output item - * @returns {string|null} Description string or null - */ - function getItemDetails(item) { - if (item.labels && Array.isArray(item.labels)) { - return `${item.labels.length} labels`; - } - if (item.reviewers && Array.isArray(item.reviewers)) { - return `${item.reviewers.length} reviewers`; - } - return null; - } - - /** - * Sanitize and deduplicate an array of string items - * @param {any[]} items - Raw items array - * @returns {string[]} Sanitized and deduplicated array - */ - function sanitizeItems(items) { - return items - .filter(item => item != null && item !== false && item !== 0) - .map(item => String(item).trim()) - .filter(item => item) - .filter((item, index, arr) => arr.indexOf(item) === index); - } - - /** - * Filter items by allowed list - * @param {string[]} items - Items to filter - * @param {string[]|undefined} allowed - Allowed items list (undefined means all allowed) - * @returns {string[]} Filtered items - */ - function filterByAllowed(items, allowed) { - if (!allowed || allowed.length === 0) { - return items; - } - return items.filter(item => allowed.includes(item)); - } - - /** - * Limit items to max count - * @param {string[]} items - Items to limit - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Limited items - */ - function limitToMaxCount(items, maxCount) { - if (items.length > maxCount) { - core.info(`Too many items (${items.length}), limiting to ${maxCount}`); - return items.slice(0, maxCount); - } - return items; - } - - /** - * Process items through the standard pipeline: filter by allowed, sanitize, dedupe, limit - * @param {any[]} rawItems - Raw items array from agent output - * @param {string[]|undefined} allowed - Allowed items list - * @param {number} maxCount - Maximum number of items - * @returns {string[]} Processed items - */ - function processItems(rawItems, allowed, maxCount) { - // Filter by allowed list first - const filtered = filterByAllowed(rawItems, allowed); - - // Sanitize and deduplicate - const sanitized = sanitizeItems(filtered); - - // Limit to max count - return limitToMaxCount(sanitized, maxCount); - } - - module.exports = { - processSafeOutput, - sanitizeItems, - filterByAllowed, - limitToMaxCount, - processItems, - }; - - EOF_8f3864e2 - cat > /tmp/gh-aw/scripts/safe_output_validator.cjs << 'EOF_437e6b4f' - // @ts-check - /// - - const fs = require("fs"); - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - - /** - * Load and parse the safe outputs configuration from config.json - * @returns {object} The parsed configuration object - */ - function loadSafeOutputsConfig() { - const configPath = "/tmp/gh-aw/safeoutputs/config.json"; - try { - if (!fs.existsSync(configPath)) { - core.warning(`Config file not found at ${configPath}, using defaults`); - return {}; - } - const configContent = fs.readFileSync(configPath, "utf8"); - return JSON.parse(configContent); - } catch (error) { - core.warning(`Failed to load config: ${error instanceof Error ? error.message : String(error)}`); - return {}; - } - } - - /** - * Get configuration for a specific safe output type - * @param {string} outputType - The type of safe output (e.g., "add_labels", "update_issue") - * @returns {{max?: number, target?: string, allowed?: string[]}} The configuration for this output type - */ - function getSafeOutputConfig(outputType) { - const config = loadSafeOutputsConfig(); - return config[outputType] || {}; - } - - /** - * Validate and sanitize a title string - * @param {any} title - The title to validate - * @param {string} fieldName - The name of the field for error messages (default: "title") - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateTitle(title, fieldName = "title") { - if (title === undefined || title === null) { - return { valid: false, error: `${fieldName} is required` }; - } - - if (typeof title !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - const trimmed = title.trim(); - if (trimmed.length === 0) { - return { valid: false, error: `${fieldName} cannot be empty` }; - } - - return { valid: true, value: trimmed }; - } - - /** - * Validate and sanitize a body/content string - * @param {any} body - The body to validate - * @param {string} fieldName - The name of the field for error messages (default: "body") - * @param {boolean} required - Whether the body is required (default: false) - * @returns {{valid: boolean, value?: string, error?: string}} Validation result - */ - function validateBody(body, fieldName = "body", required = false) { - if (body === undefined || body === null) { - if (required) { - return { valid: false, error: `${fieldName} is required` }; - } - return { valid: true, value: "" }; - } - - if (typeof body !== "string") { - return { valid: false, error: `${fieldName} must be a string` }; - } - - return { valid: true, value: body }; - } - - /** - * Validate and sanitize an array of labels - * @param {any} labels - The labels to validate - * @param {string[]|undefined} allowedLabels - Optional list of allowed labels - * @param {number} maxCount - Maximum number of labels allowed - * @returns {{valid: boolean, value?: string[], error?: string}} Validation result - */ - function validateLabels(labels, allowedLabels = undefined, maxCount = 3) { - if (!labels || !Array.isArray(labels)) { - return { valid: false, error: "labels must be an array" }; - } - - // Check for removal attempts (labels starting with '-') - for (const label of labels) { - if (label && typeof label === "string" && label.startsWith("-")) { - return { valid: false, error: `Label removal is not permitted. Found line starting with '-': ${label}` }; - } - } - - // Filter labels based on allowed list if provided - let validLabels = labels; - if (allowedLabels && allowedLabels.length > 0) { - validLabels = labels.filter(label => allowedLabels.includes(label)); - } - - // Sanitize and deduplicate labels - const uniqueLabels = validLabels - .filter(label => label != null && label !== false && label !== 0) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - - // Apply max count limit - if (uniqueLabels.length > maxCount) { - core.info(`Too many labels (${uniqueLabels.length}), limiting to ${maxCount}`); - return { valid: true, value: uniqueLabels.slice(0, maxCount) }; - } - - if (uniqueLabels.length === 0) { - return { valid: false, error: "No valid labels found after sanitization" }; - } - - return { valid: true, value: uniqueLabels }; - } - - /** - * Validate max count from environment variable with config fallback - * @param {string|undefined} envValue - Environment variable value - * @param {number|undefined} configDefault - Default from config.json - * @param {number} [fallbackDefault] - Fallback default for testing (optional, defaults to 1) - * @returns {{valid: true, value: number} | {valid: false, error: string}} Validation result - */ - function validateMaxCount(envValue, configDefault, fallbackDefault = 1) { - // Priority: env var > config.json > fallback default - // In production, config.json should always have the default - // Fallback is provided for backward compatibility and testing - const defaultValue = configDefault !== undefined ? configDefault : fallbackDefault; - - if (!envValue) { - return { valid: true, value: defaultValue }; - } - - const parsed = parseInt(envValue, 10); - if (isNaN(parsed) || parsed < 1) { - return { - valid: false, - error: `Invalid max value: ${envValue}. Must be a positive integer`, - }; - } - - return { valid: true, value: parsed }; - } - - module.exports = { - loadSafeOutputsConfig, - getSafeOutputConfig, - validateTitle, - validateBody, - validateLabels, - validateMaxCount, - }; - - EOF_437e6b4f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_ISSUE_EXPIRES: "1" - GH_AW_WORKFLOW_NAME: "Smoke Copilot" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 📰 *BREAKING: Report filed by [{workflow_name}]({run_url})*\",\"runStarted\":\"📰 BREAKING: [{workflow_name}]({run_url}) is now investigating this {event_type}. Sources say the story is developing...\",\"runSuccess\":\"📰 VERDICT: [{workflow_name}]({run_url}) has concluded. All systems operational. This is a developing story. 🎤\",\"runFailure\":\"📰 DEVELOPING STORY: [{workflow_name}]({run_url}) reports {status}. Our correspondents are investigating the incident...\"}" - with: +GH_AW_ISSUE_EXPIRES=1 with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; + summaryContent += `**Repository:** ${item.repo}\n\n`; } if (item.body) { - content += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; - } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; - } - return content; - }, - }); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } - } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; + summaryContent += "---\n\n"; } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; - } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; } await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); + return; } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); - } - (async () => { - await main(); - })(); - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_HIDE_OLDER_COMMENTS: "true" - GH_AW_CREATED_ISSUE_URL: ${{ steps.create_issue.outputs.issue_url }} - GH_AW_CREATED_ISSUE_NUMBER: ${{ steps.create_issue.outputs.issue_number }} - GH_AW_TEMPORARY_ID_MAP: ${{ steps.create_issue.outputs.temporary_id_map }} - GH_AW_WORKFLOW_NAME: "Smoke Copilot" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 📰 *BREAKING: Report filed by [{workflow_name}]({run_url})*\",\"runStarted\":\"📰 BREAKING: [{workflow_name}]({run_url}) is now investigating this {event_type}. Sources say the story is developing...\",\"runSuccess\":\"📰 VERDICT: [{workflow_name}]({run_url}) has concluded. All systems operational. This is a developing story. 🎤\",\"runFailure\":\"📰 DEVELOPING STORY: [{workflow_name}]({run_url}) reports {status}. Our correspondents are investigating the incident...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); } - page++; } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); - } - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; - } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; - } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); - return; - } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; - } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; - } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; - } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); - } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); - } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); - } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); - - name: Add Labels - id: add_labels - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_labels')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_LABELS_ALLOWED: "smoke-copilot" - GH_AW_WORKFLOW_NAME: "Smoke Copilot" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 📰 *BREAKING: Report filed by [{workflow_name}]({run_url})*\",\"runStarted\":\"📰 BREAKING: [{workflow_name}]({run_url}) is now investigating this {event_type}. Sources say the story is developing...\",\"runSuccess\":\"📰 VERDICT: [{workflow_name}]({run_url}) has concluded. All systems operational. This is a developing story. 🎤\",\"runFailure\":\"📰 DEVELOPING STORY: [{workflow_name}]({run_url}) reports {status}. Our correspondents are investigating the incident...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { processSafeOutput } = require('/tmp/gh-aw/scripts/safe_output_processor.cjs'); - const { validateLabels } = require('/tmp/gh-aw/scripts/safe_output_validator.cjs'); - async function main() { - const result = await processSafeOutput( - { - itemType: "add_labels", - configKey: "add_labels", - displayName: "Labels", - itemTypeName: "label addition", - supportsPR: true, - supportsIssue: true, - envVars: { - allowed: "GH_AW_LABELS_ALLOWED", - maxCount: "GH_AW_LABELS_MAX_COUNT", - target: "GH_AW_LABELS_TARGET", - }, - }, - { - title: "Add Labels", - description: "The following labels would be added if staged mode was disabled:", - renderItem: item => { - let content = ""; - if (item.item_number) { - content += `**Target Issue:** #${item.item_number}\n\n`; - } else { - content += `**Target:** Current issue/PR\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels to add:** ${item.labels.join(", ")}\n\n`; - } - return content; - }, - } - ); - if (!result.success) { - return; - } - const { item: labelsItem, config, targetResult } = result; - if (!config || !targetResult || targetResult.number === undefined) { - core.setFailed("Internal error: config, targetResult, or targetResult.number is undefined"); - return; - } - const { allowed: allowedLabels, maxCount } = config; - const itemNumber = targetResult.number; - const { contextType } = targetResult; - const requestedLabels = labelsItem.labels || []; - core.info(`Requested labels: ${JSON.stringify(requestedLabels)}`); - const labelsResult = validateLabels(requestedLabels, allowedLabels, maxCount); - if (!labelsResult.valid) { - if (labelsResult.error && labelsResult.error.includes("No valid labels")) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` - ## Label Addition - No labels were added (no valid labels found in agent output). - ` - ) - .write(); - return; - } - core.setFailed(labelsResult.error || "Invalid labels"); - return; - } - const uniqueLabels = labelsResult.value || []; - if (uniqueLabels.length === 0) { - core.info("No labels to add"); - core.setOutput("labels_added", ""); - await core.summary - .addRaw( - ` - ## Label Addition - No labels were added (no valid labels found in agent output). - ` - ) - .write(); - return; - } - core.info(`Adding ${uniqueLabels.length} labels to ${contextType} #${itemNumber}: ${JSON.stringify(uniqueLabels)}`); + (async () => { try { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - labels: uniqueLabels, - }); - core.info(`Successfully added ${uniqueLabels.length} labels to ${contextType} #${itemNumber}`); - core.setOutput("labels_added", uniqueLabels.join("\n")); - const labelsListMarkdown = uniqueLabels.map(label => `- \`${label}\``).join("\n"); - await core.summary - .addRaw( - ` - ## Label Addition - Successfully added ${uniqueLabels.length} label(s) to ${contextType} #${itemNumber}: - ${labelsListMarkdown} - ` - ) - .write(); + await main(); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to add labels: ${errorMessage}`); - core.setFailed(`Failed to add labels: ${errorMessage}`); + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/smoke-detector.lock.yml b/.github/workflows/smoke-detector.lock.yml index bc4d724420..9f75f8e97d 100644 --- a/.github/workflows/smoke-detector.lock.yml +++ b/.github/workflows/smoke-detector.lock.yml @@ -7106,11 +7106,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7123,1663 +7128,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_ISSUE_TITLE_PREFIX: "[smoke-detector] " GH_AW_ISSUE_LABELS: "smoke-test,investigation" - GH_AW_ISSUE_EXPIRES: "1" - GH_AW_WORKFLOW_NAME: "Smoke Detector - Smoke Test Failure Investigator" - GH_AW_ENGINE_ID: "claude" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🚨 *Alert generated by [{workflow_name}]({run_url})*\",\"runStarted\":\"🔥 BEEP BEEP! [{workflow_name}]({run_url}) detected smoke on this {event_type}! Investigating...\",\"runSuccess\":\"🚨 All clear! [{workflow_name}]({run_url}) has completed the investigation. Fire report filed! 📋\",\"runFailure\":\"🔥 Alarm malfunction! [{workflow_name}]({run_url}) {status}. Manual inspection required...\"}" - with: +GH_AW_ISSUE_EXPIRES=1 with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; + summaryContent += `**Repository:** ${item.repo}\n\n`; } if (item.body) { - content += `**Body:**\n${item.body}\n\n`; + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; - } - return content; - }, - }); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; - } - } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; + summaryContent += "---\n\n"; } } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } - } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; - } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; - } await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); + return; } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); - } - (async () => { - await main(); - })(); - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_COMMENT_TARGET: "*" - GH_AW_HIDE_OLDER_COMMENTS: "true" - GH_AW_CREATED_ISSUE_URL: ${{ steps.create_issue.outputs.issue_url }} - GH_AW_CREATED_ISSUE_NUMBER: ${{ steps.create_issue.outputs.issue_number }} - GH_AW_TEMPORARY_ID_MAP: ${{ steps.create_issue.outputs.temporary_id_map }} - GH_AW_WORKFLOW_NAME: "Smoke Detector - Smoke Test Failure Investigator" - GH_AW_ENGINE_ID: "claude" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🚨 *Alert generated by [{workflow_name}]({run_url})*\",\"runStarted\":\"🔥 BEEP BEEP! [{workflow_name}]({run_url}) detected smoke on this {event_type}! Investigating...\",\"runSuccess\":\"🚨 All clear! [{workflow_name}]({run_url}) has completed the investigation. Fire report filed! 📋\",\"runFailure\":\"🔥 Alarm malfunction! [{workflow_name}]({run_url}) {status}. Manual inspection required...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } } - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; - } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); - return; } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; - } - } - } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; - } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); - } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); update_cache_memory: needs: diff --git a/.github/workflows/smoke-srt.lock.yml b/.github/workflows/smoke-srt.lock.yml index d8bb2f9166..86b387316a 100644 --- a/.github/workflows/smoke-srt.lock.yml +++ b/.github/workflows/smoke-srt.lock.yml @@ -5679,6 +5679,7 @@ jobs: - activation - agent - detection + - safe_outputs if: (always()) && (needs.agent.result != 'skipped') runs-on: ubuntu-slim permissions: @@ -6588,3 +6589,205 @@ jobs: } await main(); + safe_outputs: + needs: + - agent + - detection + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.detection.outputs.success == 'true') + runs-on: ubuntu-slim + timeout-minutes: 15 + outputs: + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} + steps: + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); + async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); + return; + } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; + } + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); + return; + } + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; + } + } + } + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } + } + await core.summary.addRaw(summaryContent).write(); + } + core.info(`Completed processing ${result.items.length} safe output item(s)`); + } + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); + diff --git a/.github/workflows/spec-kit-execute.lock.yml b/.github/workflows/spec-kit-execute.lock.yml index df22af31b6..59fbc4afe8 100644 --- a/.github/workflows/spec-kit-execute.lock.yml +++ b/.github/workflows/spec-kit-execute.lock.yml @@ -7142,8 +7142,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7156,776 +7164,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Create Pull Request - id: create_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PR_TITLE_PREFIX: "[spec-kit] " GH_AW_PR_LABELS: "spec-kit,automation" - GH_AW_PR_DRAFT: "false" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Spec-Kit Execute" - GH_AW_TRACKER_ID: "spec-kit-execute" - GH_AW_ENGINE_ID: "copilot" - with: + GH_AW_PR_TITLE_PREFIX: "[spec-kit] " +GH_AW_PR_DRAFT=false with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { - core.setOutput("pull_request_number", ""); - core.setOutput("pull_request_url", ""); core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); - core.setOutput("branch_name", ""); - core.setOutput("fallback_used", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const workflowId = process.env.GH_AW_WORKFLOW_ID; - if (!workflowId) { - throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); - } - const baseBranch = process.env.GH_AW_BASE_BRANCH; - if (!baseBranch) { - throw new Error("GH_AW_BASE_BRANCH environment variable is required"); - } - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - let outputContent = ""; - if (agentOutputFile.trim() !== "") { - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - } - const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn"; - const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - let patchContent = ""; - let isEmpty = true; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } - if (patchContent.includes("Failed to generate patch")) { - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return; - } - throw new Error(message); - } - core.info("Patch size validation passed"); - } - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to push - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); return; } - core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - if (!title) { - title = "Agent Output"; - } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - const labelsEnv = process.env.GH_AW_PR_LABELS; - const labels = labelsEnv - ? labelsEnv - .split(",") - .map( label => label.trim()) - .filter( label => label) - : []; - const draftEnv = process.env.GH_AW_PR_DRAFT; - const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); - const randomHex = crypto.randomBytes(8).toString("hex"); - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); } else { - branchName = `${branchName}-${randomHex}`; - core.info(`Using branch name from JSONL with added salt: ${branchName}`); - } - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); - core.info(`Fetching latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${baseBranch}`); - core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`); - await exec.exec(`git checkout -b ${branchName}`); - core.info(`Created new branch from base: ${branchName}`); - if (!isEmpty) { - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - try { - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - try { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Changes pushed to branch"); - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - > [!NOTE] - > This was originally intended as a pull request, but the git push operation failed. - > - > **Workflow Run:** [View run details and download patch artifact](${runUrl}) - > - > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above. - To apply the patch locally: - \`\`\`sh - # Download the artifact from the workflow run ${runUrl} - # (Use GitHub MCP tools if gh CLI is not available) - gh run download ${runId} -n aw.patch - # Apply the patch - git am aw.patch - \`\`\` - ${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - core.setOutput("push_failed", "true"); - await core.summary - .addRaw( - ` - ## Push Failure Fallback - - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} - - **Fallback Issue:** [#${issue.number}](${issue.html_url}) - - **Patch Artifact:** Available in workflow run artifacts - - **Note:** Push failed, created issue as fallback - ` - ) - .write(); - return; - } catch (issueError) { - core.setFailed( - `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` - ); - return; } } - } else { - core.info("Skipping patch application (empty patch)"); - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - try { - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Empty branch pushed successfully"); - } catch (pushError) { - core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - return; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - } else { - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to apply - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } + await core.summary.addRaw(summaryContent).write(); } + core.info(`Completed processing ${result.items.length} safe output item(s)`); + } + (async () => { try { - const { data: pullRequest } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }); - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - labels: labels, - }); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } - core.setOutput("pull_request_number", pullRequest.number); - core.setOutput("pull_request_url", pullRequest.html_url); - core.setOutput("branch_name", branchName); - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - await core.summary - .addRaw( - ` - ## Pull Request - - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - - **Branch**: \`${branchName}\` - - **Base Branch**: \`${baseBranch}\` - ` - ) - .write(); - } catch (prError) { - core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); - core.info("Falling back to creating an issue instead"); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). - **Original error:** ${prError instanceof Error ? prError.message : String(prError)} - You can manually create a pull request from the branch if needed.${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - await core.summary - .addRaw( - ` - ## Fallback Issue Created - - **Issue**: [#${issue.number}](${issue.html_url}) - - **Branch**: [\`${branchName}\`](${branchUrl}) - - **Base Branch**: \`${baseBranch}\` - - **Note**: Pull request creation failed, created issue as fallback - ` - ) - .write(); - } catch (issueError) { - core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`); - return; - } + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/spec-kit-executor.lock.yml b/.github/workflows/spec-kit-executor.lock.yml index d40beaca36..1df166178c 100644 --- a/.github/workflows/spec-kit-executor.lock.yml +++ b/.github/workflows/spec-kit-executor.lock.yml @@ -7017,8 +7017,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7031,776 +7039,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Create Pull Request - id: create_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PR_TITLE_PREFIX: "[spec-kit] " GH_AW_PR_LABELS: "spec-kit,automation" - GH_AW_PR_DRAFT: "false" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Spec Kit Executor" - GH_AW_TRACKER_ID: "spec-kit-executor" - GH_AW_ENGINE_ID: "copilot" - with: + GH_AW_PR_TITLE_PREFIX: "[spec-kit] " +GH_AW_PR_DRAFT=false with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { - core.setOutput("pull_request_number", ""); - core.setOutput("pull_request_url", ""); core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); - core.setOutput("branch_name", ""); - core.setOutput("fallback_used", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const workflowId = process.env.GH_AW_WORKFLOW_ID; - if (!workflowId) { - throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); - } - const baseBranch = process.env.GH_AW_BASE_BRANCH; - if (!baseBranch) { - throw new Error("GH_AW_BASE_BRANCH environment variable is required"); - } - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - let outputContent = ""; - if (agentOutputFile.trim() !== "") { - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - } - const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn"; - const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - let patchContent = ""; - let isEmpty = true; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } - if (patchContent.includes("Failed to generate patch")) { - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return; - } - throw new Error(message); - } - core.info("Patch size validation passed"); - } - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to push - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); return; } - core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - if (!title) { - title = "Agent Output"; - } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - const labelsEnv = process.env.GH_AW_PR_LABELS; - const labels = labelsEnv - ? labelsEnv - .split(",") - .map( label => label.trim()) - .filter( label => label) - : []; - const draftEnv = process.env.GH_AW_PR_DRAFT; - const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); - const randomHex = crypto.randomBytes(8).toString("hex"); - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); } else { - branchName = `${branchName}-${randomHex}`; - core.info(`Using branch name from JSONL with added salt: ${branchName}`); - } - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); - core.info(`Fetching latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${baseBranch}`); - core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`); - await exec.exec(`git checkout -b ${branchName}`); - core.info(`Created new branch from base: ${branchName}`); - if (!isEmpty) { - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - try { - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - try { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Changes pushed to branch"); - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - > [!NOTE] - > This was originally intended as a pull request, but the git push operation failed. - > - > **Workflow Run:** [View run details and download patch artifact](${runUrl}) - > - > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above. - To apply the patch locally: - \`\`\`sh - # Download the artifact from the workflow run ${runUrl} - # (Use GitHub MCP tools if gh CLI is not available) - gh run download ${runId} -n aw.patch - # Apply the patch - git am aw.patch - \`\`\` - ${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - core.setOutput("push_failed", "true"); - await core.summary - .addRaw( - ` - ## Push Failure Fallback - - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} - - **Fallback Issue:** [#${issue.number}](${issue.html_url}) - - **Patch Artifact:** Available in workflow run artifacts - - **Note:** Push failed, created issue as fallback - ` - ) - .write(); - return; - } catch (issueError) { - core.setFailed( - `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` - ); - return; } } - } else { - core.info("Skipping patch application (empty patch)"); - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - try { - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Empty branch pushed successfully"); - } catch (pushError) { - core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - return; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - } else { - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to apply - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; + } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } + await core.summary.addRaw(summaryContent).write(); } + core.info(`Completed processing ${result.items.length} safe output item(s)`); + } + (async () => { try { - const { data: pullRequest } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }); - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - labels: labels, - }); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } - core.setOutput("pull_request_number", pullRequest.number); - core.setOutput("pull_request_url", pullRequest.html_url); - core.setOutput("branch_name", branchName); - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - await core.summary - .addRaw( - ` - ## Pull Request - - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - - **Branch**: \`${branchName}\` - - **Base Branch**: \`${baseBranch}\` - ` - ) - .write(); - } catch (prError) { - core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); - core.info("Falling back to creating an issue instead"); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). - **Original error:** ${prError instanceof Error ? prError.message : String(prError)} - You can manually create a pull request from the branch if needed.${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - await core.summary - .addRaw( - ` - ## Fallback Issue Created - - **Issue**: [#${issue.number}](${issue.html_url}) - - **Branch**: [\`${branchName}\`](${branchUrl}) - - **Base Branch**: \`${baseBranch}\` - - **Note**: Pull request creation failed, created issue as fallback - ` - ) - .write(); - } catch (issueError) { - core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`); - return; - } + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/speckit-dispatcher.lock.yml b/.github/workflows/speckit-dispatcher.lock.yml index b2c756f181..a2f3852b31 100644 --- a/.github/workflows/speckit-dispatcher.lock.yml +++ b/.github/workflows/speckit-dispatcher.lock.yml @@ -7939,11 +7939,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7956,1973 +7961,175 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Spec-Kit Command Dispatcher" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🎯 *Spec-Kit dispatcher by [{workflow_name}]({run_url})*\",\"runStarted\":\"🔍 Analyzing your spec-kit request via [{workflow_name}]({run_url})...\",\"runSuccess\":\"✅ Guidance provided! [{workflow_name}]({run_url}) has determined the next steps.\",\"runFailure\":\"❌ Analysis incomplete. [{workflow_name}]({run_url}) {status}.\"}" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; + summaryContent += `**Repository:** ${item.repo}\n\n`; } if (item.body) { - content += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; - } - return content; - }, - }); - return; - } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } - } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } - } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); + summaryContent += "---\n\n"; } } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } - } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; - } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; - } await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); + return; } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); - } - (async () => { - await main(); - })(); - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_CREATED_ISSUE_URL: ${{ steps.create_issue.outputs.issue_url }} - GH_AW_CREATED_ISSUE_NUMBER: ${{ steps.create_issue.outputs.issue_number }} - GH_AW_TEMPORARY_ID_MAP: ${{ steps.create_issue.outputs.temporary_id_map }} - GH_AW_WORKFLOW_NAME: "Spec-Kit Command Dispatcher" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🎯 *Spec-Kit dispatcher by [{workflow_name}]({run_url})*\",\"runStarted\":\"🔍 Analyzing your spec-kit request via [{workflow_name}]({run_url})...\",\"runSuccess\":\"✅ Guidance provided! [{workflow_name}]({run_url}) has determined the next steps.\",\"runFailure\":\"❌ Analysis incomplete. [{workflow_name}]({run_url}) {status}.\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); } - page++; } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); - } - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; - } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; - } - } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); - return; - } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; - } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; - } + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; - } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); - } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); - } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); - } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); - - name: Link Sub Issue - id: link_sub_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'link_sub_issue')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_CREATED_ISSUE_NUMBER: ${{ steps.create_issue.outputs.issue_number }} - GH_AW_TEMPORARY_ID_MAP: ${{ steps.create_issue.outputs.temporary_id_map }} - GH_AW_WORKFLOW_NAME: "Spec-Kit Command Dispatcher" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🎯 *Spec-Kit dispatcher by [{workflow_name}]({run_url})*\",\"runStarted\":\"🔍 Analyzing your spec-kit request via [{workflow_name}]({run_url})...\",\"runSuccess\":\"✅ Guidance provided! [{workflow_name}]({run_url}) has determined the next steps.\",\"runFailure\":\"❌ Analysis incomplete. [{workflow_name}]({run_url}) {status}.\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { loadTemporaryIdMap, resolveIssueNumber } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - async function main() { - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const linkItems = result.items.filter(item => item.type === "link_sub_issue"); - if (linkItems.length === 0) { - core.info("No link_sub_issue items found in agent output"); - return; - } - core.info(`Found ${linkItems.length} link_sub_issue item(s)`); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - await generateStagedPreview({ - title: "Link Sub-Issue", - description: "The following sub-issue links would be created if staged mode was disabled:", - items: linkItems, - renderItem: item => { - const parentResolved = resolveIssueNumber(item.parent_issue_number, temporaryIdMap); - const subResolved = resolveIssueNumber(item.sub_issue_number, temporaryIdMap); - let parentDisplay = parentResolved.resolved ? `${parentResolved.resolved.repo}#${parentResolved.resolved.number}` : `${item.parent_issue_number} (unresolved)`; - let subDisplay = subResolved.resolved ? `${subResolved.resolved.repo}#${subResolved.resolved.number}` : `${item.sub_issue_number} (unresolved)`; - if (parentResolved.wasTemporaryId && parentResolved.resolved) { - parentDisplay += ` (from ${item.parent_issue_number})`; - } - if (subResolved.wasTemporaryId && subResolved.resolved) { - subDisplay += ` (from ${item.sub_issue_number})`; - } - let content = `**Parent Issue:** ${parentDisplay}\n`; - content += `**Sub-Issue:** ${subDisplay}\n\n`; - return content; - }, - }); - return; - } - const parentRequiredLabelsEnv = process.env.GH_AW_LINK_SUB_ISSUE_PARENT_REQUIRED_LABELS?.trim(); - const parentRequiredLabels = parentRequiredLabelsEnv - ? parentRequiredLabelsEnv - .split(",") - .map(l => l.trim()) - .filter(l => l) - : []; - const parentTitlePrefix = process.env.GH_AW_LINK_SUB_ISSUE_PARENT_TITLE_PREFIX?.trim() || ""; - const subRequiredLabelsEnv = process.env.GH_AW_LINK_SUB_ISSUE_SUB_REQUIRED_LABELS?.trim(); - const subRequiredLabels = subRequiredLabelsEnv - ? subRequiredLabelsEnv - .split(",") - .map(l => l.trim()) - .filter(l => l) - : []; - const subTitlePrefix = process.env.GH_AW_LINK_SUB_ISSUE_SUB_TITLE_PREFIX?.trim() || ""; - if (parentRequiredLabels.length > 0) { - core.info(`Parent required labels: ${JSON.stringify(parentRequiredLabels)}`); - } - if (parentTitlePrefix) { - core.info(`Parent title prefix: ${parentTitlePrefix}`); - } - if (subRequiredLabels.length > 0) { - core.info(`Sub-issue required labels: ${JSON.stringify(subRequiredLabels)}`); - } - if (subTitlePrefix) { - core.info(`Sub-issue title prefix: ${subTitlePrefix}`); - } - const maxCountEnv = process.env.GH_AW_LINK_SUB_ISSUE_MAX_COUNT; - const maxCount = maxCountEnv ? parseInt(maxCountEnv, 10) : 5; - if (isNaN(maxCount) || maxCount < 1) { - core.setFailed(`Invalid max value: ${maxCountEnv}. Must be a positive integer`); - return; - } - core.info(`Max count: ${maxCount}`); - const itemsToProcess = linkItems.slice(0, maxCount); - if (linkItems.length > maxCount) { - core.warning(`Found ${linkItems.length} link_sub_issue items, but max is ${maxCount}. Processing first ${maxCount}.`); - } - const results = []; - for (const item of itemsToProcess) { - const parentResolved = resolveIssueNumber(item.parent_issue_number, temporaryIdMap); - const subResolved = resolveIssueNumber(item.sub_issue_number, temporaryIdMap); - if (parentResolved.errorMessage) { - core.warning(`Failed to resolve parent issue: ${parentResolved.errorMessage}`); - results.push({ - parent_issue_number: item.parent_issue_number, - sub_issue_number: item.sub_issue_number, - success: false, - error: parentResolved.errorMessage, - }); - continue; - } - if (subResolved.errorMessage) { - core.warning(`Failed to resolve sub-issue: ${subResolved.errorMessage}`); - results.push({ - parent_issue_number: item.parent_issue_number, - sub_issue_number: item.sub_issue_number, - success: false, - error: subResolved.errorMessage, - }); - continue; - } - const parentIssueNumber = parentResolved.resolved.number; - const subIssueNumber = subResolved.resolved.number; - if (parentResolved.wasTemporaryId) { - core.info(`Resolved parent temporary ID '${item.parent_issue_number}' to ${parentResolved.resolved.repo}#${parentIssueNumber}`); - } - if (subResolved.wasTemporaryId) { - core.info(`Resolved sub-issue temporary ID '${item.sub_issue_number}' to ${subResolved.resolved.repo}#${subIssueNumber}`); - } - let parentIssue; - try { - const parentResponse = await github.rest.issues.get({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: parentIssueNumber, - }); - parentIssue = parentResponse.data; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.warning(`Failed to fetch parent issue #${parentIssueNumber}: ${errorMessage}`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: false, - error: `Failed to fetch parent issue: ${errorMessage}`, - }); - continue; - } - if (parentRequiredLabels.length > 0) { - const parentLabels = parentIssue.labels.map(l => (typeof l === "string" ? l : l.name || "")); - const missingLabels = parentRequiredLabels.filter(required => !parentLabels.includes(required)); - if (missingLabels.length > 0) { - core.warning(`Parent issue #${parentIssueNumber} is missing required labels: ${missingLabels.join(", ")}. Skipping.`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: false, - error: `Parent issue missing required labels: ${missingLabels.join(", ")}`, - }); - continue; - } - } - if (parentTitlePrefix && !parentIssue.title.startsWith(parentTitlePrefix)) { - core.warning(`Parent issue #${parentIssueNumber} title does not start with "${parentTitlePrefix}". Skipping.`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: false, - error: `Parent issue title does not start with "${parentTitlePrefix}"`, - }); - continue; - } - let subIssue; - try { - const subResponse = await github.rest.issues.get({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: subIssueNumber, - }); - subIssue = subResponse.data; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to fetch sub-issue #${subIssueNumber}: ${errorMessage}`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: false, - error: `Failed to fetch sub-issue: ${errorMessage}`, - }); - continue; - } - try { - const parentCheckQuery = ` - query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $number) { - parent { - number - title - } - } - } - } - `; - const parentCheckResult = await github.graphql(parentCheckQuery, { - owner: context.repo.owner, - repo: context.repo.repo, - number: subIssueNumber, - }); - const existingParent = parentCheckResult?.repository?.issue?.parent; - if (existingParent) { - core.warning(`Sub-issue #${subIssueNumber} is already a sub-issue of #${existingParent.number} ("${existingParent.title}"). Skipping.`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: false, - error: `Sub-issue is already a sub-issue of #${existingParent.number}`, - }); - continue; - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.warning(`Could not check if sub-issue #${subIssueNumber} has a parent: ${errorMessage}. Proceeding with link attempt.`); - } - if (subRequiredLabels.length > 0) { - const subLabels = subIssue.labels.map(l => (typeof l === "string" ? l : l.name || "")); - const missingLabels = subRequiredLabels.filter(required => !subLabels.includes(required)); - if (missingLabels.length > 0) { - core.warning(`Sub-issue #${subIssueNumber} is missing required labels: ${missingLabels.join(", ")}. Skipping.`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: false, - error: `Sub-issue missing required labels: ${missingLabels.join(", ")}`, - }); - continue; - } - } - if (subTitlePrefix && !subIssue.title.startsWith(subTitlePrefix)) { - core.warning(`Sub-issue #${subIssueNumber} title does not start with "${subTitlePrefix}". Skipping.`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: false, - error: `Sub-issue title does not start with "${subTitlePrefix}"`, - }); - continue; - } - try { - const parentNodeId = parentIssue.node_id; - const subNodeId = subIssue.node_id; - await github.graphql( - ` - mutation AddSubIssue($parentId: ID!, $subIssueId: ID!) { - addSubIssue(input: { issueId: $parentId, subIssueId: $subIssueId }) { - issue { - id - number - } - subIssue { - id - number - } - } - } - `, - { - parentId: parentNodeId, - subIssueId: subNodeId, - } - ); - core.info(`Successfully linked issue #${subIssueNumber} as sub-issue of #${parentIssueNumber}`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: true, - }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.warning(`Failed to link issue #${subIssueNumber} as sub-issue of #${parentIssueNumber}: ${errorMessage}`); - results.push({ - parent_issue_number: parentIssueNumber, - sub_issue_number: subIssueNumber, - success: false, - error: errorMessage, - }); - } - } - const successCount = results.filter(r => r.success).length; - const failureCount = results.filter(r => !r.success).length; - let summaryContent = "## Link Sub-Issue\n\n"; - if (successCount > 0) { - summaryContent += `✅ Successfully linked ${successCount} sub-issue(s):\n\n`; - for (const result of results.filter(r => r.success)) { - summaryContent += `- Issue #${result.sub_issue_number} → Parent #${result.parent_issue_number}\n`; - } - summaryContent += "\n"; - } - if (failureCount > 0) { - summaryContent += `⚠️ Failed to link ${failureCount} sub-issue(s):\n\n`; - for (const result of results.filter(r => !r.success)) { - summaryContent += `- Issue #${result.sub_issue_number} → Parent #${result.parent_issue_number}: ${result.error}\n`; - } - } - await core.summary.addRaw(summaryContent).write(); - const linkedIssues = results - .filter(r => r.success) - .map(r => `${r.parent_issue_number}:${r.sub_issue_number}`) - .join("\n"); - core.setOutput("linked_issues", linkedIssues); - if (failureCount > 0) { - core.warning(`Failed to link ${failureCount} sub-issue(s). See step summary for details.`); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - } - (async () => { await main(); })(); + })(); diff --git a/.github/workflows/stale-repo-identifier.lock.yml b/.github/workflows/stale-repo-identifier.lock.yml index 51b68d2e5a..c497ab7c12 100644 --- a/.github/workflows/stale-repo-identifier.lock.yml +++ b/.github/workflows/stale-repo-identifier.lock.yml @@ -7567,9 +7567,16 @@ jobs: issues: write timeout-minutes: 15 outputs: - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7582,1086 +7589,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_ISSUE_TITLE_PREFIX: "[Stale Repository] " GH_AW_ISSUE_LABELS: "stale-repository,automated-analysis" - GH_AW_WORKFLOW_NAME: "Stale Repository Identifier" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🔍 *Analysis by [{workflow_name}]({run_url})*\",\"runStarted\":\"🔍 Stale Repository Identifier starting! [{workflow_name}]({run_url}) is analyzing repository activity...\",\"runSuccess\":\"✅ Analysis complete! [{workflow_name}]({run_url}) has finished analyzing stale repositories.\",\"runFailure\":\"⚠️ Analysis interrupted! [{workflow_name}]({run_url}) {status}.\"}" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; + summaryContent += `**Repository:** ${item.repo}\n\n`; } if (item.body) { - content += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } - return content; - }, - }); + summaryContent += "---\n\n"; + } + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } - } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); - } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } await core.summary.addRaw(summaryContent).write(); } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } (async () => { - await main(); - })(); - - name: Upload Assets - id: upload_assets - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'upload_asset')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Stale Repository Identifier" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🔍 *Analysis by [{workflow_name}]({run_url})*\",\"runStarted\":\"🔍 Stale Repository Identifier starting! [{workflow_name}]({run_url}) is analyzing repository activity...\",\"runSuccess\":\"✅ Analysis complete! [{workflow_name}]({run_url}) has finished analyzing stale repositories.\",\"runFailure\":\"⚠️ Analysis interrupted! [{workflow_name}]({run_url}) {status}.\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const path = require("path"); - const crypto = require("crypto"); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function normalizeBranchName(branchName) { - if (!branchName || typeof branchName !== "string" || branchName.trim() === "") { - return branchName; - } - let normalized = branchName.replace(/[^a-zA-Z0-9\-_/.]+/g, "-"); - normalized = normalized.replace(/-+/g, "-"); - normalized = normalized.replace(/^-+|-+$/g, ""); - if (normalized.length > 128) { - normalized = normalized.substring(0, 128); - } - normalized = normalized.replace(/-+$/, ""); - normalized = normalized.toLowerCase(); - return normalized; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const branchName = process.env.GH_AW_ASSETS_BRANCH; - if (!branchName || typeof branchName !== "string") { - core.setFailed("GH_AW_ASSETS_BRANCH environment variable is required but not set"); - return; - } - const normalizedBranchName = normalizeBranchName(branchName); - core.info(`Using assets branch: ${normalizedBranchName}`); - const result = loadAgentOutput(); - if (!result.success) { - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - const uploadItems = result.items.filter( item => item.type === "upload_assets"); - const uploadAssetItems = result.items.filter( item => item.type === "upload_asset"); - const allUploadItems = [...uploadItems, ...uploadAssetItems]; - if (allUploadItems.length === 0) { - core.info("No upload-asset items found in agent output"); - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - core.info(`Found ${allUploadItems.length} upload-asset item(s)`); - let uploadCount = 0; - let hasChanges = false; try { - try { - await exec.exec(`git rev-parse --verify origin/${normalizedBranchName}`); - await exec.exec(`git checkout -B ${normalizedBranchName} origin/${normalizedBranchName}`); - core.info(`Checked out existing branch from origin: ${normalizedBranchName}`); - } catch (originError) { - if (!normalizedBranchName.startsWith("assets/")) { - core.setFailed( - `Branch '${normalizedBranchName}' does not start with the required 'assets/' prefix. ` + - `Orphaned branches can only be automatically created under the 'assets/' prefix. ` + - `Please create the branch manually first, or use a branch name starting with 'assets/'.` - ); - return; - } - core.info(`Creating new orphaned branch: ${normalizedBranchName}`); - await exec.exec(`git checkout --orphan ${normalizedBranchName}`); - await exec.exec(`git rm -rf .`); - await exec.exec(`git clean -fdx`); - } - for (const asset of uploadAssetItems) { - try { - const { fileName, sha, size, targetFileName } = asset; - if (!fileName || !sha || !targetFileName) { - core.error(`Invalid asset entry missing required fields: ${JSON.stringify(asset)}`); - continue; - } - const assetSourcePath = path.join("/tmp/gh-aw/safeoutputs/assets", fileName); - if (!fs.existsSync(assetSourcePath)) { - core.warning(`Asset file not found: ${assetSourcePath}`); - continue; - } - const fileContent = fs.readFileSync(assetSourcePath); - const computedSha = crypto.createHash("sha256").update(fileContent).digest("hex"); - if (computedSha !== sha) { - core.warning(`SHA mismatch for ${fileName}: expected ${sha}, got ${computedSha}`); - continue; - } - if (fs.existsSync(targetFileName)) { - core.info(`Asset ${targetFileName} already exists, skipping`); - continue; - } - fs.copyFileSync(assetSourcePath, targetFileName); - await exec.exec(`git add "${targetFileName}"`); - uploadCount++; - hasChanges = true; - core.info(`Added asset: ${targetFileName} (${size} bytes)`); - } catch (error) { - core.warning(`Failed to process asset ${asset.fileName}: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (hasChanges) { - const commitMessage = `[skip-ci] Add ${uploadCount} asset(s)`; - await exec.exec(`git`, [`commit`, `-m`, commitMessage]); - if (isStaged) { - core.summary.addRaw("## Staged Asset Publication"); - } else { - await exec.exec(`git push origin ${normalizedBranchName}`); - core.summary.addRaw("## Assets").addRaw(`Successfully uploaded **${uploadCount}** assets to branch \`${normalizedBranchName}\``).addRaw(""); - core.info(`Successfully uploaded ${uploadCount} assets to branch ${normalizedBranchName}`); - } - for (const asset of uploadAssetItems) { - if (asset.fileName && asset.sha && asset.size && asset.url) { - core.summary.addRaw(`- [\`${asset.fileName}\`](${asset.url}) → \`${asset.targetFileName}\` (${asset.size} bytes)`); - } - } - core.summary.write(); - } else { - core.info("No new assets to upload"); - } + await main(); } catch (error) { - core.setFailed(`Failed to upload assets: ${error instanceof Error ? error.message : String(error)}`); - return; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - core.setOutput("upload_count", uploadCount.toString()); - core.setOutput("branch_name", normalizedBranchName); - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/static-analysis-report.lock.yml b/.github/workflows/static-analysis-report.lock.yml index 4cc132b7cb..c2b6a35908 100644 --- a/.github/workflows/static-analysis-report.lock.yml +++ b/.github/workflows/static-analysis-report.lock.yml @@ -6542,8 +6542,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6556,1173 +6564,178 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Static Analysis Report" - GH_AW_ENGINE_ID: "claude" - with: + GH_AW_DISCUSSION_CATEGORY: "security" +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; - } - } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); } } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); update_cache_memory: needs: diff --git a/.github/workflows/sub-issue-closer.lock.yml b/.github/workflows/sub-issue-closer.lock.yml index 102048a4fb..e532bd42bd 100644 --- a/.github/workflows/sub-issue-closer.lock.yml +++ b/.github/workflows/sub-issue-closer.lock.yml @@ -6625,8 +6625,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6639,1592 +6647,175 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - cat > /tmp/gh-aw/scripts/update_context_helpers.cjs << 'EOF_95d23c7d' - // @ts-check - /// - - /** - * Shared context helper functions for update workflows (issues, pull requests, etc.) - * - * This module provides reusable functions for determining if we're in a valid - * context for updating a specific entity type and extracting entity numbers - * from GitHub event payloads. - * - * @module update_context_helpers - */ - - /** - * Check if the current context is a valid issue context - * @param {string} eventName - GitHub event name - * @param {any} _payload - GitHub event payload (unused but kept for interface consistency) - * @returns {boolean} Whether context is valid for issue updates - */ - function isIssueContext(eventName, _payload) { - return eventName === "issues" || eventName === "issue_comment"; - } - - /** - * Get issue number from the context payload - * @param {any} payload - GitHub event payload - * @returns {number|undefined} Issue number or undefined - */ - function getIssueNumber(payload) { - return payload?.issue?.number; - } - - /** - * Check if the current context is a valid pull request context - * @param {string} eventName - GitHub event name - * @param {any} payload - GitHub event payload - * @returns {boolean} Whether context is valid for PR updates - */ - function isPRContext(eventName, payload) { - const isPR = eventName === "pull_request" || eventName === "pull_request_review" || eventName === "pull_request_review_comment" || eventName === "pull_request_target"; - - // Also check for issue_comment on a PR - const isIssueCommentOnPR = eventName === "issue_comment" && payload?.issue && payload?.issue?.pull_request; - - return isPR || !!isIssueCommentOnPR; - } - - /** - * Get pull request number from the context payload - * @param {any} payload - GitHub event payload - * @returns {number|undefined} PR number or undefined - */ - function getPRNumber(payload) { - if (payload?.pull_request) { - return payload.pull_request.number; - } - // For issue_comment events on PRs, the PR number is in issue.number - if (payload?.issue && payload?.issue?.pull_request) { - return payload.issue.number; - } - return undefined; - } - - module.exports = { - isIssueContext, - getIssueNumber, - isPRContext, - getPRNumber, - }; - - EOF_95d23c7d - cat > /tmp/gh-aw/scripts/update_runner.cjs << 'EOF_006d32d7' - // @ts-check - /// - - /** - * Shared update runner for safe-output scripts (update_issue, update_pull_request, etc.) - * - * This module depends on GitHub Actions environment globals provided by actions/github-script: - * - core: @actions/core module for logging and outputs - * - github: @octokit/rest instance for GitHub API calls - * - context: GitHub Actions context with event payload and repository info - * - * @module update_runner - */ - - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - - /** - * @typedef {Object} UpdateRunnerConfig - * @property {string} itemType - Type of item in agent output (e.g., "update_issue", "update_pull_request") - * @property {string} displayName - Human-readable name (e.g., "issue", "pull request") - * @property {string} displayNamePlural - Human-readable plural name (e.g., "issues", "pull requests") - * @property {string} numberField - Field name for explicit number (e.g., "issue_number", "pull_request_number") - * @property {string} outputNumberKey - Output key for number (e.g., "issue_number", "pull_request_number") - * @property {string} outputUrlKey - Output key for URL (e.g., "issue_url", "pull_request_url") - * @property {(eventName: string, payload: any) => boolean} isValidContext - Function to check if context is valid - * @property {(payload: any) => number|undefined} getContextNumber - Function to get number from context payload - * @property {boolean} supportsStatus - Whether this type supports status updates - * @property {boolean} supportsOperation - Whether this type supports operation (append/prepend/replace) - * @property {(item: any, index: number) => string} renderStagedItem - Function to render item for staged preview - * @property {(github: any, context: any, targetNumber: number, updateData: any) => Promise} executeUpdate - Function to execute the update API call - * @property {(result: any) => string} getSummaryLine - Function to generate summary line for an updated item - */ - - /** - * Resolve the target number for an update operation - * @param {Object} params - Resolution parameters - * @param {string} params.updateTarget - Target configuration ("triggering", "*", or explicit number) - * @param {any} params.item - Update item with optional explicit number field - * @param {string} params.numberField - Field name for explicit number - * @param {boolean} params.isValidContext - Whether current context is valid - * @param {number|undefined} params.contextNumber - Number from triggering context - * @param {string} params.displayName - Display name for error messages - * @returns {{success: true, number: number} | {success: false, error: string}} - */ - function resolveTargetNumber(params) { - const { updateTarget, item, numberField, isValidContext, contextNumber, displayName } = params; - - if (updateTarget === "*") { - // For target "*", we need an explicit number from the update item - const explicitNumber = item[numberField]; - if (explicitNumber) { - const parsed = parseInt(explicitNumber, 10); - if (isNaN(parsed) || parsed <= 0) { - return { success: false, error: `Invalid ${numberField} specified: ${explicitNumber}` }; - } - return { success: true, number: parsed }; - } else { - return { success: false, error: `Target is "*" but no ${numberField} specified in update item` }; - } - } else if (updateTarget && updateTarget !== "triggering") { - // Explicit number specified in target - const parsed = parseInt(updateTarget, 10); - if (isNaN(parsed) || parsed <= 0) { - return { success: false, error: `Invalid ${displayName} number in target configuration: ${updateTarget}` }; - } - return { success: true, number: parsed }; - } else { - // Default behavior: use triggering context - if (isValidContext && contextNumber) { - return { success: true, number: contextNumber }; - } - return { success: false, error: `Could not determine ${displayName} number` }; - } - } - - /** - * Build update data based on allowed fields and provided values - * @param {Object} params - Build parameters - * @param {any} params.item - Update item with field values - * @param {boolean} params.canUpdateStatus - Whether status updates are allowed - * @param {boolean} params.canUpdateTitle - Whether title updates are allowed - * @param {boolean} params.canUpdateBody - Whether body updates are allowed - * @param {boolean} params.supportsStatus - Whether this type supports status - * @returns {{hasUpdates: boolean, updateData: any, logMessages: string[]}} - */ - function buildUpdateData(params) { - const { item, canUpdateStatus, canUpdateTitle, canUpdateBody, supportsStatus } = params; - - /** @type {any} */ - const updateData = {}; - let hasUpdates = false; - const logMessages = []; - - // Handle status update (only for types that support it, like issues) - if (supportsStatus && canUpdateStatus && item.status !== undefined) { - if (item.status === "open" || item.status === "closed") { - updateData.state = item.status; - hasUpdates = true; - logMessages.push(`Will update status to: ${item.status}`); - } else { - logMessages.push(`Invalid status value: ${item.status}. Must be 'open' or 'closed'`); - } - } - - // Handle title update - let titleForDedup = null; - if (canUpdateTitle && item.title !== undefined) { - const trimmedTitle = typeof item.title === "string" ? item.title.trim() : ""; - if (trimmedTitle.length > 0) { - updateData.title = trimmedTitle; - titleForDedup = trimmedTitle; - hasUpdates = true; - logMessages.push(`Will update title to: ${trimmedTitle}`); - } else { - logMessages.push("Invalid title value: must be a non-empty string"); - } - } - - // Handle body update (with title deduplication) - if (canUpdateBody && item.body !== undefined) { - if (typeof item.body === "string") { - let processedBody = item.body; - - // If we're updating the title at the same time, remove duplicate title from body - if (titleForDedup) { - processedBody = removeDuplicateTitleFromDescription(titleForDedup, processedBody); - } - - updateData.body = processedBody; - hasUpdates = true; - logMessages.push(`Will update body (length: ${processedBody.length})`); - } else { - logMessages.push("Invalid body value: must be a string"); - } - } - - return { hasUpdates, updateData, logMessages }; - } - - /** - * Run the update workflow with the provided configuration - * @param {UpdateRunnerConfig} config - Configuration for the update runner - * @returns {Promise} Array of updated items or undefined - */ - async function runUpdateWorkflow(config) { - const { itemType, displayName, displayNamePlural, numberField, outputNumberKey, outputUrlKey, isValidContext, getContextNumber, supportsStatus, supportsOperation, renderStagedItem, executeUpdate, getSummaryLine } = config; - - // Check if we're in staged mode - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - - const result = loadAgentOutput(); - if (!result.success) { - return; - } - - // Find all update items - const updateItems = result.items.filter(/** @param {any} item */ item => item.type === itemType); - if (updateItems.length === 0) { - core.info(`No ${itemType} items found in agent output`); - return; - } - - core.info(`Found ${updateItems.length} ${itemType} item(s)`); - - // If in staged mode, emit step summary instead of updating - if (isStaged) { - await generateStagedPreview({ - title: `Update ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}`, - description: `The following ${displayName} updates would be applied if staged mode was disabled:`, - items: updateItems, - renderItem: renderStagedItem, - }); - return; - } - - // Get the configuration from environment variables - const updateTarget = process.env.GH_AW_UPDATE_TARGET || "triggering"; - const canUpdateStatus = process.env.GH_AW_UPDATE_STATUS === "true"; - const canUpdateTitle = process.env.GH_AW_UPDATE_TITLE === "true"; - const canUpdateBody = process.env.GH_AW_UPDATE_BODY === "true"; - - core.info(`Update target configuration: ${updateTarget}`); - if (supportsStatus) { - core.info(`Can update status: ${canUpdateStatus}, title: ${canUpdateTitle}, body: ${canUpdateBody}`); - } else { - core.info(`Can update title: ${canUpdateTitle}, body: ${canUpdateBody}`); - } - - // Check context validity - const contextIsValid = isValidContext(context.eventName, context.payload); - const contextNumber = getContextNumber(context.payload); - - // Validate context based on target configuration - if (updateTarget === "triggering" && !contextIsValid) { - core.info(`Target is "triggering" but not running in ${displayName} context, skipping ${displayName} update`); - return; - } - - const updatedItems = []; - - // Process each update item - for (let i = 0; i < updateItems.length; i++) { - const updateItem = updateItems[i]; - core.info(`Processing ${itemType} item ${i + 1}/${updateItems.length}`); - - // Resolve target number - const targetResult = resolveTargetNumber({ - updateTarget, - item: updateItem, - numberField, - isValidContext: contextIsValid, - contextNumber, - displayName, - }); - - if (!targetResult.success) { - core.info(targetResult.error); - continue; - } - - const targetNumber = targetResult.number; - core.info(`Updating ${displayName} #${targetNumber}`); - - // Build update data - const { hasUpdates, updateData, logMessages } = buildUpdateData({ - item: updateItem, - canUpdateStatus, - canUpdateTitle, - canUpdateBody, - supportsStatus, - }); - - // Log all messages - for (const msg of logMessages) { - core.info(msg); - } - - // Handle body operation for types that support it (like PRs with append/prepend) - if (supportsOperation && canUpdateBody && updateItem.body !== undefined && typeof updateItem.body === "string") { - // The body was already added by buildUpdateData, but we need to handle operations - // This will be handled by the executeUpdate function for PR-specific logic - updateData._operation = updateItem.operation || "append"; - updateData._rawBody = updateItem.body; - } - - if (!hasUpdates) { - core.info("No valid updates to apply for this item"); - continue; - } - - try { - // Execute the update using the provided function - const updatedItem = await executeUpdate(github, context, targetNumber, updateData); - core.info(`Updated ${displayName} #${updatedItem.number}: ${updatedItem.html_url}`); - updatedItems.push(updatedItem); - - // Set output for the last updated item (for backward compatibility) - if (i === updateItems.length - 1) { - core.setOutput(outputNumberKey, updatedItem.number); - core.setOutput(outputUrlKey, updatedItem.html_url); - } - } catch (error) { - core.error(`✗ Failed to update ${displayName} #${targetNumber}: ${error instanceof Error ? error.message : String(error)}`); - throw error; - } - } - - // Write summary for all updated items - if (updatedItems.length > 0) { - let summaryContent = `\n\n## Updated ${displayNamePlural.charAt(0).toUpperCase() + displayNamePlural.slice(1)}\n`; - for (const item of updatedItems) { - summaryContent += getSummaryLine(item); - } - await core.summary.addRaw(summaryContent).write(); - } - - core.info(`Successfully updated ${updatedItems.length} ${displayName}(s)`); - return updatedItems; - } - - /** - * @typedef {Object} RenderStagedItemConfig - * @property {string} entityName - Display name for the entity (e.g., "Issue", "Pull Request") - * @property {string} numberField - Field name for the target number (e.g., "issue_number", "pull_request_number") - * @property {string} targetLabel - Label for the target (e.g., "Target Issue:", "Target PR:") - * @property {string} currentTargetText - Text when targeting current entity (e.g., "Current issue", "Current pull request") - * @property {boolean} [includeOperation=false] - Whether to include operation field for body updates - */ - - /** - * Create a render function for staged preview items - * @param {RenderStagedItemConfig} config - Configuration for the renderer - * @returns {(item: any, index: number) => string} Render function - */ - function createRenderStagedItem(config) { - const { entityName, numberField, targetLabel, currentTargetText, includeOperation = false } = config; - - return function renderStagedItem(item, index) { - let content = `#### ${entityName} Update ${index + 1}\n`; - if (item[numberField]) { - content += `**${targetLabel}** #${item[numberField]}\n\n`; - } else { - content += `**Target:** ${currentTargetText}\n\n`; - } - - if (item.title !== undefined) { - content += `**New Title:** ${item.title}\n\n`; - } - if (item.body !== undefined) { - if (includeOperation) { - const operation = item.operation || "append"; - content += `**Operation:** ${operation}\n`; - content += `**Body Content:**\n${item.body}\n\n`; - } else { - content += `**New Body:**\n${item.body}\n\n`; - } - } - if (item.status !== undefined) { - content += `**New Status:** ${item.status}\n\n`; - } - return content; - }; - } - - /** - * @typedef {Object} SummaryLineConfig - * @property {string} entityPrefix - Prefix for the summary line (e.g., "Issue", "PR") - */ - - /** - * Create a summary line generator function - * @param {SummaryLineConfig} config - Configuration for the summary generator - * @returns {(item: any) => string} Summary line generator function - */ - function createGetSummaryLine(config) { - const { entityPrefix } = config; - - return function getSummaryLine(item) { - return `- ${entityPrefix} #${item.number}: [${item.title}](${item.html_url})\n`; - }; - } - - module.exports = { - runUpdateWorkflow, - resolveTargetNumber, - buildUpdateData, - createRenderStagedItem, - createGetSummaryLine, - }; - - EOF_006d32d7 - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_COMMENT_TARGET: "*" - GH_AW_WORKFLOW_NAME: "Sub-Issue Closer" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } - } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; - } - (async () => { await main(); })(); - - name: Update Issue - id: update_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'update_issue')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Sub-Issue Closer" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { runUpdateWorkflow, createRenderStagedItem, createGetSummaryLine } = require('/tmp/gh-aw/scripts/update_runner.cjs'); - const { isIssueContext, getIssueNumber } = require('/tmp/gh-aw/scripts/update_context_helpers.cjs'); - const renderStagedItem = createRenderStagedItem({ - entityName: "Issue", - numberField: "issue_number", - targetLabel: "Target Issue:", - currentTargetText: "Current issue", - includeOperation: false, - }); - async function executeIssueUpdate(github, context, issueNumber, updateData) { - const { _operation, _rawBody, ...apiData } = updateData; - const { data: issue } = await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - ...apiData, - }); - return issue; - } - const getSummaryLine = createGetSummaryLine({ - entityPrefix: "Issue", - }); - async function main() { - return await runUpdateWorkflow({ - itemType: "update_issue", - displayName: "issue", - displayNamePlural: "issues", - numberField: "issue_number", - outputNumberKey: "issue_number", - outputUrlKey: "issue_url", - isValidContext: isIssueContext, - getContextNumber: getIssueNumber, - supportsStatus: true, - supportsOperation: false, - renderStagedItem, - executeUpdate: executeIssueUpdate, - getSummaryLine, - }); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); diff --git a/.github/workflows/super-linter.lock.yml b/.github/workflows/super-linter.lock.yml index ea9ee4fd2d..958e41913c 100644 --- a/.github/workflows/super-linter.lock.yml +++ b/.github/workflows/super-linter.lock.yml @@ -6763,9 +6763,16 @@ jobs: issues: write timeout-minutes: 15 outputs: - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6778,945 +6785,178 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_ISSUE_TITLE_PREFIX: "[linter] " GH_AW_ISSUE_LABELS: "automation,code-quality" - GH_AW_WORKFLOW_NAME: "Super Linter Report" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; + summaryContent += `**Repository:** ${item.repo}\n\n`; } if (item.body) { - content += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } - return content; - }, - }); + summaryContent += "---\n\n"; + } + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } - } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; - } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; } await core.summary.addRaw(summaryContent).write(); } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } (async () => { - await main(); + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } })(); super_linter: diff --git a/.github/workflows/technical-doc-writer.lock.yml b/.github/workflows/technical-doc-writer.lock.yml index 8884ea7747..a2733b12f3 100644 --- a/.github/workflows/technical-doc-writer.lock.yml +++ b/.github/workflows/technical-doc-writer.lock.yml @@ -7046,10 +7046,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} - create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7062,1904 +7068,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Create Pull Request - id: create_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PR_TITLE_PREFIX: "[docs] " GH_AW_PR_LABELS: "documentation" - GH_AW_PR_DRAFT: "false" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Technical Doc Writer" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 📝 *Documentation by [{workflow_name}]({run_url})*\",\"runStarted\":\"✍️ The Technical Writer begins! [{workflow_name}]({run_url}) is documenting this {event_type}...\",\"runSuccess\":\"📝 Documentation complete! [{workflow_name}]({run_url}) has written the docs. Clear as crystal! ✨\",\"runFailure\":\"✍️ Writer's block! [{workflow_name}]({run_url}) {status}. The page remains blank...\"}" - with: + GH_AW_PR_TITLE_PREFIX: "[docs] " +GH_AW_PR_DRAFT=false with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { - core.setOutput("pull_request_number", ""); - core.setOutput("pull_request_url", ""); core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); - core.setOutput("branch_name", ""); - core.setOutput("fallback_used", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const workflowId = process.env.GH_AW_WORKFLOW_ID; - if (!workflowId) { - throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); - } - const baseBranch = process.env.GH_AW_BASE_BRANCH; - if (!baseBranch) { - throw new Error("GH_AW_BASE_BRANCH environment variable is required"); - } - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - let outputContent = ""; - if (agentOutputFile.trim() !== "") { - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - } - const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn"; - const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - let patchContent = ""; - let isEmpty = true; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } - if (patchContent.includes("Failed to generate patch")) { - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return; - } - throw new Error(message); - } - core.info("Patch size validation passed"); - } - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to push - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); return; } - core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; - } - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); - return; - } - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - if (!title) { - title = "Agent Output"; - } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - const labelsEnv = process.env.GH_AW_PR_LABELS; - const labels = labelsEnv - ? labelsEnv - .split(",") - .map( label => label.trim()) - .filter( label => label) - : []; - const draftEnv = process.env.GH_AW_PR_DRAFT; - const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); - const randomHex = crypto.randomBytes(8).toString("hex"); - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; - } else { - branchName = `${branchName}-${randomHex}`; - core.info(`Using branch name from JSONL with added salt: ${branchName}`); - } - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); - core.info(`Fetching latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${baseBranch}`); - core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`); - await exec.exec(`git checkout -b ${branchName}`); - core.info(`Created new branch from base: ${branchName}`); - if (!isEmpty) { - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - try { - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - try { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Changes pushed to branch"); - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - > [!NOTE] - > This was originally intended as a pull request, but the git push operation failed. - > - > **Workflow Run:** [View run details and download patch artifact](${runUrl}) - > - > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above. - To apply the patch locally: - \`\`\`sh - # Download the artifact from the workflow run ${runUrl} - # (Use GitHub MCP tools if gh CLI is not available) - gh run download ${runId} -n aw.patch - # Apply the patch - git am aw.patch - \`\`\` - ${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - core.setOutput("push_failed", "true"); - await core.summary - .addRaw( - ` - ## Push Failure Fallback - - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} - - **Fallback Issue:** [#${issue.number}](${issue.html_url}) - - **Patch Artifact:** Available in workflow run artifacts - - **Note:** Push failed, created issue as fallback - ` - ) - .write(); - return; - } catch (issueError) { - core.setFailed( - `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` - ); - return; - } - } - } else { - core.info("Skipping patch application (empty patch)"); - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - try { - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; } - await exec.exec(`git push origin ${branchName}`); - core.info("Empty branch pushed successfully"); - } catch (pushError) { - core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - return; - } - } else { - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to apply - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - try { - const { data: pullRequest } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }); - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - labels: labels, - }); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } - core.setOutput("pull_request_number", pullRequest.number); - core.setOutput("pull_request_url", pullRequest.html_url); - core.setOutput("branch_name", branchName); - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - await core.summary - .addRaw( - ` - ## Pull Request - - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - - **Branch**: \`${branchName}\` - - **Base Branch**: \`${baseBranch}\` - ` - ) - .write(); - } catch (prError) { - core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); - core.info("Falling back to creating an issue instead"); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). - **Original error:** ${prError instanceof Error ? prError.message : String(prError)} - You can manually create a pull request from the branch if needed.${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - await core.summary - .addRaw( - ` - ## Fallback Issue Created - - **Issue**: [#${issue.number}](${issue.html_url}) - - **Branch**: [\`${branchName}\`](${branchUrl}) - - **Base Branch**: \`${baseBranch}\` - - **Note**: Pull request creation failed, created issue as fallback - ` - ) - .write(); - } catch (issueError) { - core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`); - return; - } - } - } - (async () => { await main(); })(); - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_CREATED_PULL_REQUEST_URL: ${{ steps.create_pull_request.outputs.pull_request_url }} - GH_AW_CREATED_PULL_REQUEST_NUMBER: ${{ steps.create_pull_request.outputs.pull_request_number }} - GH_AW_WORKFLOW_NAME: "Technical Doc Writer" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 📝 *Documentation by [{workflow_name}]({run_url})*\",\"runStarted\":\"✍️ The Technical Writer begins! [{workflow_name}]({run_url}) is documenting this {event_type}...\",\"runSuccess\":\"📝 Documentation complete! [{workflow_name}]({run_url}) has written the docs. Clear as crystal! ✨\",\"runFailure\":\"✍️ Writer's block! [{workflow_name}]({run_url}) {status}. The page remains blank...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } + summaryContent += "---\n\n"; } } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } - } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } - const result = loadAgentOutput(); - if (!result.success) { - return; - } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; - } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); - return; } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; - } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; - } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); - } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; - } - (async () => { await main(); })(); - - name: Upload Assets - id: upload_assets - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'upload_asset')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Technical Doc Writer" - GH_AW_ENGINE_ID: "copilot" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 📝 *Documentation by [{workflow_name}]({run_url})*\",\"runStarted\":\"✍️ The Technical Writer begins! [{workflow_name}]({run_url}) is documenting this {event_type}...\",\"runSuccess\":\"📝 Documentation complete! [{workflow_name}]({run_url}) has written the docs. Clear as crystal! ✨\",\"runFailure\":\"✍️ Writer's block! [{workflow_name}]({run_url}) {status}. The page remains blank...\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const path = require("path"); - const crypto = require("crypto"); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function normalizeBranchName(branchName) { - if (!branchName || typeof branchName !== "string" || branchName.trim() === "") { - return branchName; - } - let normalized = branchName.replace(/[^a-zA-Z0-9\-_/.]+/g, "-"); - normalized = normalized.replace(/-+/g, "-"); - normalized = normalized.replace(/^-+|-+$/g, ""); - if (normalized.length > 128) { - normalized = normalized.substring(0, 128); - } - normalized = normalized.replace(/-+$/, ""); - normalized = normalized.toLowerCase(); - return normalized; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const branchName = process.env.GH_AW_ASSETS_BRANCH; - if (!branchName || typeof branchName !== "string") { - core.setFailed("GH_AW_ASSETS_BRANCH environment variable is required but not set"); - return; - } - const normalizedBranchName = normalizeBranchName(branchName); - core.info(`Using assets branch: ${normalizedBranchName}`); - const result = loadAgentOutput(); - if (!result.success) { - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - const uploadItems = result.items.filter( item => item.type === "upload_assets"); - const uploadAssetItems = result.items.filter( item => item.type === "upload_asset"); - const allUploadItems = [...uploadItems, ...uploadAssetItems]; - if (allUploadItems.length === 0) { - core.info("No upload-asset items found in agent output"); - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - core.info(`Found ${allUploadItems.length} upload-asset item(s)`); - let uploadCount = 0; - let hasChanges = false; + (async () => { try { - try { - await exec.exec(`git rev-parse --verify origin/${normalizedBranchName}`); - await exec.exec(`git checkout -B ${normalizedBranchName} origin/${normalizedBranchName}`); - core.info(`Checked out existing branch from origin: ${normalizedBranchName}`); - } catch (originError) { - if (!normalizedBranchName.startsWith("assets/")) { - core.setFailed( - `Branch '${normalizedBranchName}' does not start with the required 'assets/' prefix. ` + - `Orphaned branches can only be automatically created under the 'assets/' prefix. ` + - `Please create the branch manually first, or use a branch name starting with 'assets/'.` - ); - return; - } - core.info(`Creating new orphaned branch: ${normalizedBranchName}`); - await exec.exec(`git checkout --orphan ${normalizedBranchName}`); - await exec.exec(`git rm -rf .`); - await exec.exec(`git clean -fdx`); - } - for (const asset of uploadAssetItems) { - try { - const { fileName, sha, size, targetFileName } = asset; - if (!fileName || !sha || !targetFileName) { - core.error(`Invalid asset entry missing required fields: ${JSON.stringify(asset)}`); - continue; - } - const assetSourcePath = path.join("/tmp/gh-aw/safeoutputs/assets", fileName); - if (!fs.existsSync(assetSourcePath)) { - core.warning(`Asset file not found: ${assetSourcePath}`); - continue; - } - const fileContent = fs.readFileSync(assetSourcePath); - const computedSha = crypto.createHash("sha256").update(fileContent).digest("hex"); - if (computedSha !== sha) { - core.warning(`SHA mismatch for ${fileName}: expected ${sha}, got ${computedSha}`); - continue; - } - if (fs.existsSync(targetFileName)) { - core.info(`Asset ${targetFileName} already exists, skipping`); - continue; - } - fs.copyFileSync(assetSourcePath, targetFileName); - await exec.exec(`git add "${targetFileName}"`); - uploadCount++; - hasChanges = true; - core.info(`Added asset: ${targetFileName} (${size} bytes)`); - } catch (error) { - core.warning(`Failed to process asset ${asset.fileName}: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (hasChanges) { - const commitMessage = `[skip-ci] Add ${uploadCount} asset(s)`; - await exec.exec(`git`, [`commit`, `-m`, commitMessage]); - if (isStaged) { - core.summary.addRaw("## Staged Asset Publication"); - } else { - await exec.exec(`git push origin ${normalizedBranchName}`); - core.summary.addRaw("## Assets").addRaw(`Successfully uploaded **${uploadCount}** assets to branch \`${normalizedBranchName}\``).addRaw(""); - core.info(`Successfully uploaded ${uploadCount} assets to branch ${normalizedBranchName}`); - } - for (const asset of uploadAssetItems) { - if (asset.fileName && asset.sha && asset.size && asset.url) { - core.summary.addRaw(`- [\`${asset.fileName}\`](${asset.url}) → \`${asset.targetFileName}\` (${asset.size} bytes)`); - } - } - core.summary.write(); - } else { - core.info("No new assets to upload"); - } + await main(); } catch (error) { - core.setFailed(`Failed to upload assets: ${error instanceof Error ? error.message : String(error)}`); - return; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - core.setOutput("upload_count", uploadCount.toString()); - core.setOutput("branch_name", normalizedBranchName); - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/tidy.lock.yml b/.github/workflows/tidy.lock.yml index fa1ffb3102..78d25f4a24 100644 --- a/.github/workflows/tidy.lock.yml +++ b/.github/workflows/tidy.lock.yml @@ -7263,9 +7263,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} - push_to_pull_request_branch_commit_url: ${{ steps.push_to_pull_request_branch.outputs.commit_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7278,1128 +7285,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Create Pull Request - id: create_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PR_TITLE_PREFIX: "[tidy] " GH_AW_PR_LABELS: "automation,maintenance" - GH_AW_PR_DRAFT: "false" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Tidy" - GH_AW_ENGINE_ID: "copilot" - with: + GH_AW_PR_TITLE_PREFIX: "[tidy] " +GH_AW_PR_DRAFT=false with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { - core.setOutput("pull_request_number", ""); - core.setOutput("pull_request_url", ""); core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); - core.setOutput("branch_name", ""); - core.setOutput("fallback_used", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const workflowId = process.env.GH_AW_WORKFLOW_ID; - if (!workflowId) { - throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); - } - const baseBranch = process.env.GH_AW_BASE_BRANCH; - if (!baseBranch) { - throw new Error("GH_AW_BASE_BRANCH environment variable is required"); - } - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - let outputContent = ""; - if (agentOutputFile.trim() !== "") { - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - } - const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn"; - const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - let patchContent = ""; - let isEmpty = true; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } - if (patchContent.includes("Failed to generate patch")) { - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return; - } - throw new Error(message); - } - core.info("Patch size validation passed"); - } - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to push - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); return; } - core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - if (!title) { - title = "Agent Output"; - } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - const labelsEnv = process.env.GH_AW_PR_LABELS; - const labels = labelsEnv - ? labelsEnv - .split(",") - .map( label => label.trim()) - .filter( label => label) - : []; - const draftEnv = process.env.GH_AW_PR_DRAFT; - const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); - const randomHex = crypto.randomBytes(8).toString("hex"); - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; - } else { - branchName = `${branchName}-${randomHex}`; - core.info(`Using branch name from JSONL with added salt: ${branchName}`); - } - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); - core.info(`Fetching latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${baseBranch}`); - core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`); - await exec.exec(`git checkout -b ${branchName}`); - core.info(`Created new branch from base: ${branchName}`); - if (!isEmpty) { - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - try { - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - try { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Changes pushed to branch"); - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - > [!NOTE] - > This was originally intended as a pull request, but the git push operation failed. - > - > **Workflow Run:** [View run details and download patch artifact](${runUrl}) - > - > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above. - To apply the patch locally: - \`\`\`sh - # Download the artifact from the workflow run ${runUrl} - # (Use GitHub MCP tools if gh CLI is not available) - gh run download ${runId} -n aw.patch - # Apply the patch - git am aw.patch - \`\`\` - ${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - core.setOutput("push_failed", "true"); - await core.summary - .addRaw( - ` - ## Push Failure Fallback - - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} - - **Fallback Issue:** [#${issue.number}](${issue.html_url}) - - **Patch Artifact:** Available in workflow run artifacts - - **Note:** Push failed, created issue as fallback - ` - ) - .write(); - return; - } catch (issueError) { - core.setFailed( - `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` - ); - return; - } - } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); } else { - core.info("Skipping patch application (empty patch)"); - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - try { - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - await exec.exec(`git push origin ${branchName}`); - core.info("Empty branch pushed successfully"); - } catch (pushError) { - core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - return; - } - } else { - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to apply - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; } } } - try { - const { data: pullRequest } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }); - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - labels: labels, - }); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } - core.setOutput("pull_request_number", pullRequest.number); - core.setOutput("pull_request_url", pullRequest.html_url); - core.setOutput("branch_name", branchName); - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - await core.summary - .addRaw( - ` - ## Pull Request - - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - - **Branch**: \`${branchName}\` - - **Base Branch**: \`${baseBranch}\` - ` - ) - .write(); - } catch (prError) { - core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); - core.info("Falling back to creating an issue instead"); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). - **Original error:** ${prError instanceof Error ? prError.message : String(prError)} - You can manually create a pull request from the branch if needed.${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - await core.summary - .addRaw( - ` - ## Fallback Issue Created - - **Issue**: [#${issue.number}](${issue.html_url}) - - **Branch**: [\`${branchName}\`](${branchUrl}) - - **Base Branch**: \`${baseBranch}\` - - **Note**: Pull request creation failed, created issue as fallback - ` - ) - .write(); - } catch (issueError) { - core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`); - return; + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - } - (async () => { await main(); })(); - - name: Push To Pull Request Branch - id: push_to_pull_request_branch - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'push_to_pull_request_branch')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PUSH_IF_NO_CHANGES: "warn" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Tidy" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { updateActivationCommentWithCommit } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - if (agentOutputFile.trim() === "") { - core.info("Agent output content is empty"); - return; - } - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return; - } - const target = process.env.GH_AW_PUSH_TARGET || "triggering"; - const ifNoChanges = process.env.GH_AW_PUSH_IF_NO_CHANGES || "warn"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - const message = "No patch file found - cannot push without changes"; - switch (ifNoChanges) { - case "error": - core.setFailed(message); - return; - case "ignore": - return; - case "warn": - default: - core.info(message); - return; - } - } - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchContent.includes("Failed to generate patch")) { - const message = "Patch file contains error message - cannot push without changes"; - core.error("Patch file generation failed - this is an error condition that requires investigation"); - core.error(`Patch file location: /tmp/gh-aw/aw.patch`); - core.error(`Patch file size: ${Buffer.byteLength(patchContent, "utf8")} bytes`); - const previewLength = Math.min(500, patchContent.length); - core.error(`Patch file preview (first ${previewLength} characters):`); - core.error(patchContent.substring(0, previewLength)); - core.setFailed(message); - return; - } - const isEmpty = !patchContent || !patchContent.trim(); - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - core.setFailed(message); - return; - } - core.info("Patch size validation passed"); - } - if (isEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - core.setFailed("No changes to push - failing as configured by if-no-changes: error"); - return; - case "ignore": - break; - case "warn": - default: - core.info(message); - break; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } - core.info(`Target configuration: ${target}`); - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - return; - } - const pushItem = validatedOutput.items.find( item => item.type === "push_to_pull_request_branch"); - if (!pushItem) { - core.info("No push-to-pull-request-branch item found in agent output"); - return; - } - core.info("Found push-to-pull-request-branch item"); - if (isStaged) { - await generateStagedPreview({ - title: "Push to PR Branch", - description: "The following changes would be pushed if staged mode was disabled:", - items: [{ target, commit_message: pushItem.commit_message }], - renderItem: item => { - let content = ""; - content += `**Target:** ${item.target}\n\n`; - if (item.commit_message) { - content += `**Commit Message:** ${item.commit_message}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - content += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - content += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - content += `**Changes:** No changes (empty patch)\n\n`; - } - } - return content; - }, - }); - return; - } - if (target !== "*" && target !== "triggering") { - const pullNumber = parseInt(target, 10); - if (isNaN(pullNumber)) { - core.setFailed('Invalid target configuration: must be "triggering", "*", or a valid pull request number'); - return; - } - } - let pullNumber; - if (target === "triggering") { - pullNumber = context.payload?.pull_request?.number || context.payload?.issue?.number; - if (!pullNumber) { - core.setFailed('push-to-pull-request-branch with target "triggering" requires pull request context'); - return; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - } else if (target === "*") { - if (pushItem.pull_number) { - pullNumber = parseInt(pushItem.pull_number, 10); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } - } else { - pullNumber = parseInt(target, 10); + await core.summary.addRaw(summaryContent).write(); } - let branchName; - let prTitle = ""; - let prLabels = []; + core.info(`Completed processing ${result.items.length} safe output item(s)`); + } + (async () => { try { - const prInfoRes = await exec.getExecOutput(`gh`, [`pr`, `view`, `${pullNumber}`, `--json`, `headRefName,title,labels`, `--jq`, `{headRefName, title, labels: (.labels // [] | map(.name))}`]); - if (prInfoRes.exitCode === 0) { - const prData = JSON.parse(prInfoRes.stdout.trim()); - branchName = prData.headRefName; - prTitle = prData.title || ""; - prLabels = prData.labels || []; - } else { - throw new Error("No PR data found"); - } + await main(); } catch (error) { - core.info(`Warning: Could not fetch PR ${pullNumber} details: ${error instanceof Error ? error.message : String(error)}`); - core.setFailed(`Failed to determine branch name for PR ${pullNumber}`); - return; - } - core.info(`Target branch: ${branchName}`); - core.info(`PR title: ${prTitle}`); - core.info(`PR labels: ${prLabels.join(", ")}`); - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !prTitle.startsWith(titlePrefix)) { - core.setFailed(`Pull request title "${prTitle}" does not start with required prefix "${titlePrefix}"`); - return; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - const requiredLabelsStr = process.env.GH_AW_PR_LABELS; - if (requiredLabelsStr) { - const requiredLabels = requiredLabelsStr.split(",").map(label => label.trim()); - const missingLabels = requiredLabels.filter(label => !prLabels.includes(label)); - if (missingLabels.length > 0) { - core.setFailed(`Pull request is missing required labels: ${missingLabels.join(", ")}. Current labels: ${prLabels.join(", ")}`); - return; - } - } - if (titlePrefix) { - core.info(`✓ Title prefix validation passed: "${titlePrefix}"`); - } - if (requiredLabelsStr) { - core.info(`✓ Labels validation passed: ${requiredLabelsStr}`); - } - const hasChanges = !isEmpty; - core.info(`Switching to branch: ${branchName}`); - try { - await exec.exec("git fetch origin"); - } catch (fetchError) { - core.setFailed(`Failed to fetch from origin: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`); - return; - } - try { - await exec.exec(`git rev-parse --verify origin/${branchName}`); - } catch (verifyError) { - core.setFailed(`Branch ${branchName} does not exist on origin, can't push to it: ${verifyError instanceof Error ? verifyError.message : String(verifyError)}`); - return; - } - try { - await exec.exec(`git checkout -B ${branchName} origin/${branchName}`); - core.info(`Checked out existing branch from origin: ${branchName}`); - } catch (checkoutError) { - core.setFailed(`Failed to checkout branch ${branchName}: ${checkoutError instanceof Error ? checkoutError.message : String(checkoutError)}`); - return; - } - if (!isEmpty) { - core.info("Applying patch..."); - try { - const commitTitleSuffix = process.env.GH_AW_COMMIT_TITLE_SUFFIX; - if (commitTitleSuffix) { - core.info(`Appending commit title suffix: "${commitTitleSuffix}"`); - let patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchContent = patchContent.replace(/^Subject: (?:\[PATCH\] )?(.*)$/gm, (match, title) => `Subject: [PATCH] ${title}${commitTitleSuffix}`); - fs.writeFileSync("/tmp/gh-aw/aw.patch", patchContent, "utf8"); - core.info(`Patch modified with commit title suffix: "${commitTitleSuffix}"`); - } - const finalPatchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - const patchLines = finalPatchContent.split("\n"); - const previewLineCount = Math.min(100, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - await exec.exec(`git push origin ${branchName}`); - core.info(`Changes committed and pushed to branch: ${branchName}`); - } catch (error) { - core.error(`Failed to apply patch: ${error instanceof Error ? error.message : String(error)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const logResult = await exec.getExecOutput("git", ["log", "--oneline", "-5"]); - core.info("Recent commits (last 5):"); - core.info(logResult.stdout); - const diffResult = await exec.getExecOutput("git", ["diff", "HEAD"]); - core.info("Uncommitted changes:"); - core.info(diffResult.stdout && diffResult.stdout.trim() ? diffResult.stdout : "(no uncommitted changes)"); - const patchDiffResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch diff:"); - core.info(patchDiffResult.stdout); - const patchFullResult = await exec.getExecOutput("git", ["am", "--show-current-patch"]); - core.info("Failed patch (full):"); - core.info(patchFullResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - } else { - core.info("Skipping patch application (empty patch)"); - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - core.setFailed("No changes to apply - failing as configured by if-no-changes: error"); - return; - case "ignore": - break; - case "warn": - default: - core.info(message); - break; - } - } - const commitShaRes = await exec.getExecOutput("git", ["rev-parse", "HEAD"]); - if (commitShaRes.exitCode !== 0) throw new Error("Failed to get commit SHA"); - const commitSha = commitShaRes.stdout.trim(); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const repoUrl = context.payload.repository ? context.payload.repository.html_url : `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - const pushUrl = `${repoUrl}/tree/${branchName}`; - const commitUrl = `${repoUrl}/commit/${commitSha}`; - core.setOutput("branch_name", branchName); - core.setOutput("commit_sha", commitSha); - core.setOutput("push_url", pushUrl); - core.setOutput("commit_url", commitUrl); - if (hasChanges) { - await updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl); - } - const summaryTitle = hasChanges ? "Push to Branch" : "Push to Branch (No Changes)"; - const summaryContent = hasChanges - ? ` - ## ${summaryTitle} - - **Branch**: \`${branchName}\` - - **Commit**: [${commitSha.substring(0, 7)}](${commitUrl}) - - **URL**: [${pushUrl}](${pushUrl}) - ` - : ` - ## ${summaryTitle} - - **Branch**: \`${branchName}\` - - **Status**: No changes to apply (noop operation) - - **URL**: [${pushUrl}](${pushUrl}) - `; - await core.summary.addRaw(summaryContent).write(); - } - (async () => { await main(); })(); + })(); diff --git a/.github/workflows/typist.lock.yml b/.github/workflows/typist.lock.yml index 6bcc665650..ebd2988905 100644 --- a/.github/workflows/typist.lock.yml +++ b/.github/workflows/typist.lock.yml @@ -6703,8 +6703,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6717,1171 +6725,176 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Typist - Go Type Analysis" - GH_AW_ENGINE_ID: "claude" - with: + GH_AW_DISCUSSION_CATEGORY: "General" +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; - } - } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); } } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); + (async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } + })(); diff --git a/.github/workflows/unbloat-docs.lock.yml b/.github/workflows/unbloat-docs.lock.yml index b19fad2a0e..4a7859fe66 100644 --- a/.github/workflows/unbloat-docs.lock.yml +++ b/.github/workflows/unbloat-docs.lock.yml @@ -7260,10 +7260,16 @@ jobs: pull-requests: write timeout-minutes: 15 outputs: - add_comment_comment_id: ${{ steps.add_comment.outputs.comment_id }} - add_comment_comment_url: ${{ steps.add_comment.outputs.comment_url }} - create_pull_request_pull_request_number: ${{ steps.create_pull_request.outputs.pull_request_number }} - create_pull_request_pull_request_url: ${{ steps.create_pull_request.outputs.pull_request_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7276,1904 +7282,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash - run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_repository_url.cjs << 'EOF_75ff5f42' - // @ts-check - /// - - /** - * Get the repository URL for different purposes - * This helper handles trial mode where target repository URLs are different from execution context - * @returns {string} Repository URL - */ - function getRepositoryUrl() { - // For trial mode, use target repository for issue/PR URLs but execution context for action runs - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - - if (targetRepoSlug) { - // Use target repository for issue/PR URLs in trial mode - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${targetRepoSlug}`; - } else if (context.payload.repository?.html_url) { - // Use execution context repository (default behavior) - return context.payload.repository.html_url; - } else { - // Final fallback for action runs when context repo is not available - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - return `${githubServer}/${context.repo.owner}/${context.repo.repo}`; - } - } - - module.exports = { - getRepositoryUrl, - }; - - EOF_75ff5f42 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/messages_footer.cjs << 'EOF_c14886c6' - // @ts-check - /// - - /** - * Footer Message Module - * - * This module provides footer and installation instructions generation - * for safe-output workflows. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} FooterContext - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - * @property {string} [workflowSource] - Source of the workflow (owner/repo/path@ref) - * @property {string} [workflowSourceUrl] - GitHub URL for the workflow source - * @property {number|string} [triggeringNumber] - Issue, PR, or discussion number that triggered this workflow - */ - - /** - * Get the footer message, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer message - */ - function getFooterMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default footer template - pirate themed! 🏴‍☠️ - const defaultFooter = "> Ahoy! This treasure was crafted by [🏴‍☠️ {workflow_name}]({run_url})"; - - // Use custom footer if configured - let footer = messages?.footer ? renderTemplate(messages.footer, templateContext) : renderTemplate(defaultFooter, templateContext); - - // Add triggering reference if available - if (ctx.triggeringNumber) { - footer += ` fer issue #{triggering_number} 🗺️`.replace("{triggering_number}", String(ctx.triggeringNumber)); - } - - return footer; - } - - /** - * Get the footer installation instructions, using custom template if configured. - * @param {FooterContext} ctx - Context for footer generation - * @returns {string} Footer installation message or empty string if no source - */ - function getFooterInstallMessage(ctx) { - if (!ctx.workflowSource || !ctx.workflowSourceUrl) { - return ""; - } - - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default installation template - pirate themed! 🏴‍☠️ - const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - - // Use custom installation message if configured - return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); - } - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * The marker format is: - * - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate the complete footer with AI attribution and optional installation instructions. - * This is a drop-in replacement for the original generateFooter function. - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Complete footer text - */ - function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - // Determine triggering number (issue takes precedence, then PR, then discussion) - let triggeringNumber; - if (triggeringIssueNumber) { - triggeringNumber = triggeringIssueNumber; - } else if (triggeringPRNumber) { - triggeringNumber = triggeringPRNumber; - } else if (triggeringDiscussionNumber) { - triggeringNumber = `discussion #${triggeringDiscussionNumber}`; - } - - const ctx = { - workflowName, - runUrl, - workflowSource, - workflowSourceUrl: workflowSourceURL, - triggeringNumber, - }; - - let footer = "\n\n" + getFooterMessage(ctx); - - // Add installation instructions if source is available - const installMessage = getFooterInstallMessage(ctx); - if (installMessage) { - footer += "\n>\n" + installMessage; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - getFooterMessage, - getFooterInstallMessage, - generateFooterWithMessages, - generateXMLMarker, - }; - - EOF_c14886c6 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - cat > /tmp/gh-aw/scripts/update_activation_comment.cjs << 'EOF_967a5011' - // @ts-check - /// - - /** - * Update the activation comment with a link to the created pull request or issue - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} itemUrl - URL of the created item (pull request or issue) - * @param {number} itemNumber - Number of the item (pull request or issue) - * @param {string} itemType - Type of item: "pull_request" or "issue" (defaults to "pull_request") - */ - async function updateActivationComment(github, context, core, itemUrl, itemNumber, itemType = "pull_request") { - const itemLabel = itemType === "issue" ? "issue" : "pull request"; - const linkMessage = itemType === "issue" ? `\n\n✅ Issue created: [#${itemNumber}](${itemUrl})` : `\n\n✅ Pull request created: [#${itemNumber}](${itemUrl})`; - await updateActivationCommentWithMessage(github, context, core, linkMessage, itemLabel); - } - - /** - * Update the activation comment with a commit link - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} commitSha - SHA of the commit - * @param {string} commitUrl - URL of the commit - */ - async function updateActivationCommentWithCommit(github, context, core, commitSha, commitUrl) { - const shortSha = commitSha.substring(0, 7); - const message = `\n\n✅ Commit pushed: [\`${shortSha}\`](${commitUrl})`; - await updateActivationCommentWithMessage(github, context, core, message, "commit"); - } - - /** - * Update the activation comment with a custom message - * @param {any} github - GitHub REST API instance - * @param {any} context - GitHub Actions context - * @param {any} core - GitHub Actions core - * @param {string} message - Message to append to the comment - * @param {string} label - Optional label for log messages (e.g., "pull request", "issue", "commit") - */ - async function updateActivationCommentWithMessage(github, context, core, message, label = "") { - const commentId = process.env.GH_AW_COMMENT_ID; - const commentRepo = process.env.GH_AW_COMMENT_REPO; - - // If no comment was created in activation, skip updating - if (!commentId) { - core.info("No activation comment to update (GH_AW_COMMENT_ID not set)"); - return; - } - - core.info(`Updating activation comment ${commentId}`); - - // Parse comment repo (format: "owner/repo") with validation - let repoOwner = context.repo.owner; - let repoName = context.repo.repo; - if (commentRepo) { - const parts = commentRepo.split("/"); - if (parts.length === 2) { - repoOwner = parts[0]; - repoName = parts[1]; - } else { - core.warning(`Invalid comment repo format: ${commentRepo}, expected "owner/repo". Falling back to context.repo.`); - } - } - - core.info(`Updating comment in ${repoOwner}/${repoName}`); - - // Check if this is a discussion comment (GraphQL node ID format) - const isDiscussionComment = commentId.startsWith("DC_"); - - try { - if (isDiscussionComment) { - // Get current comment body using GraphQL - const currentComment = await github.graphql( - ` - query($commentId: ID!) { - node(id: $commentId) { - ... on DiscussionComment { - body - } - } - }`, - { commentId: commentId } - ); - - if (!currentComment?.node?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted or is inaccessible"); - return; - } - const currentBody = currentComment.node.body; - const updatedBody = currentBody + message; - - // Update discussion comment using GraphQL - const result = await github.graphql( - ` - mutation($commentId: ID!, $body: String!) { - updateDiscussionComment(input: { commentId: $commentId, body: $body }) { - comment { - id - url - } - } - }`, - { commentId: commentId, body: updatedBody } - ); - - const comment = result.updateDiscussionComment.comment; - const successMessage = label ? `Successfully updated discussion comment with ${label} link` : "Successfully updated discussion comment"; - core.info(successMessage); - core.info(`Comment ID: ${comment.id}`); - core.info(`Comment URL: ${comment.url}`); - } else { - // Get current comment body using REST API - const currentComment = await github.request("GET /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - headers: { - Accept: "application/vnd.github+json", - }, - }); - - if (!currentComment?.data?.body) { - core.warning("Unable to fetch current comment body, comment may have been deleted"); - return; - } - const currentBody = currentComment.data.body; - const updatedBody = currentBody + message; - - // Update issue/PR comment using REST API - const response = await github.request("PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", { - owner: repoOwner, - repo: repoName, - comment_id: parseInt(commentId, 10), - body: updatedBody, - headers: { - Accept: "application/vnd.github+json", - }, - }); - - const successMessage = label ? `Successfully updated comment with ${label} link` : "Successfully updated comment"; - core.info(successMessage); - core.info(`Comment ID: ${response.data.id}`); - core.info(`Comment URL: ${response.data.html_url}`); - } - } catch (error) { - // Don't fail the workflow if we can't update the comment - just log a warning - core.warning(`Failed to update activation comment: ${error instanceof Error ? error.message : String(error)}`); - } - } - - module.exports = { - updateActivationComment, - updateActivationCommentWithCommit, - }; - - EOF_967a5011 - - name: Create Pull Request - id: create_pull_request - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_pull_request')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_PR_TITLE_PREFIX: "[docs] " - GH_AW_PR_LABELS: "documentation,automation" - GH_AW_PR_DRAFT: "true" - GH_AW_MAX_PATCH_SIZE: 1024 - GH_AW_WORKFLOW_NAME: "Documentation Unbloat" - GH_AW_ENGINE_ID: "claude" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🗜️ *Compressed by [{workflow_name}]({run_url})*\",\"runStarted\":\"📦 Time to slim down! [{workflow_name}]({run_url}) is trimming the excess from this {event_type}...\",\"runSuccess\":\"🗜️ Docs on a diet! [{workflow_name}]({run_url}) has removed the bloat. Lean and mean! 💪\",\"runFailure\":\"📦 Unbloating paused! [{workflow_name}]({run_url}) {status}. The docs remain... fluffy.\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const crypto = require("crypto"); - const { updateActivationComment } = require('/tmp/gh-aw/scripts/update_activation_comment.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - function generatePatchPreview(patchContent) { - if (!patchContent || !patchContent.trim()) { - return ""; - } - const lines = patchContent.split("\n"); - const maxLines = 500; - const maxChars = 2000; - let preview = lines.length <= maxLines ? patchContent : lines.slice(0, maxLines).join("\n"); - const lineTruncated = lines.length > maxLines; - const charTruncated = preview.length > maxChars; - if (charTruncated) { - preview = preview.slice(0, maxChars); - } - const truncated = lineTruncated || charTruncated; - const summary = truncated ? `Show patch preview (${Math.min(maxLines, lines.length)} of ${lines.length} lines)` : `Show patch (${lines.length} lines)`; - return `\n\n
${summary}\n\n\`\`\`diff\n${preview}${truncated ? "\n... (truncated)" : ""}\n\`\`\`\n\n
`; - } - async function main() { - core.setOutput("pull_request_number", ""); - core.setOutput("pull_request_url", ""); - core.setOutput("issue_number", ""); - core.setOutput("issue_url", ""); - core.setOutput("branch_name", ""); - core.setOutput("fallback_used", ""); - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const workflowId = process.env.GH_AW_WORKFLOW_ID; - if (!workflowId) { - throw new Error("GH_AW_WORKFLOW_ID environment variable is required"); - } - const baseBranch = process.env.GH_AW_BASE_BRANCH; - if (!baseBranch) { - throw new Error("GH_AW_BASE_BRANCH environment variable is required"); - } - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT || ""; - let outputContent = ""; - if (agentOutputFile.trim() !== "") { - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - core.setFailed(`Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`); - return; - } - } - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - } - const ifNoChanges = process.env.GH_AW_PR_IF_NO_CHANGES || "warn"; - const allowEmpty = (process.env.GH_AW_PR_ALLOW_EMPTY || "false").toLowerCase() === "true"; - if (!fs.existsSync("/tmp/gh-aw/aw.patch")) { - if (allowEmpty) { - core.info("No patch file found, but allow-empty is enabled - will create empty PR"); - } else { - const message = "No patch file found - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ No patch file found\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (no patch file)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - let patchContent = ""; - let isEmpty = true; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - isEmpty = !patchContent || !patchContent.trim(); - } - if (patchContent.includes("Failed to generate patch")) { - if (allowEmpty) { - core.info("Patch file contains error, but allow-empty is enabled - will create empty PR"); - patchContent = ""; - isEmpty = true; - } else { - const message = "Patch file contains error message - cannot create pull request without changes"; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ⚠️ Patch file contains error\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch error)"); - return; - } - switch (ifNoChanges) { - case "error": - throw new Error(message); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - if (!isEmpty) { - const maxSizeKb = parseInt(process.env.GH_AW_MAX_PATCH_SIZE || "1024", 10); - const patchSizeBytes = Buffer.byteLength(patchContent, "utf8"); - const patchSizeKb = Math.ceil(patchSizeBytes / 1024); - core.info(`Patch size: ${patchSizeKb} KB (maximum allowed: ${maxSizeKb} KB)`); - if (patchSizeKb > maxSizeKb) { - const message = `Patch size (${patchSizeKb} KB) exceeds maximum allowed size (${maxSizeKb} KB)`; - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Status:** ❌ Patch size exceeded\n\n`; - summaryContent += `**Message:** ${message}\n\n`; - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary (patch size error)"); - return; - } - throw new Error(message); - } - core.info("Patch size validation passed"); - } - if (isEmpty && !isStaged && !allowEmpty) { - const message = "Patch file is empty - no changes to apply (noop operation)"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to push - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - core.info(`Agent output content length: ${outputContent.length}`); - if (!isEmpty) { - core.info("Patch content validation passed"); - } else if (allowEmpty) { - core.info("Patch file is empty - processing empty PR creation (allow-empty is enabled)"); - } else { - core.info("Patch file is empty - processing noop operation"); - } - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - core.setFailed(`Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`); - return; - } - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.warning("No valid items found in agent output"); - return; - } - const pullRequestItem = validatedOutput.items.find( item => item.type === "create_pull_request"); - if (!pullRequestItem) { - core.warning("No create-pull-request item found in agent output"); - return; - } - core.info(`Found create-pull-request item: title="${pullRequestItem.title}", bodyLength=${pullRequestItem.body.length}`); - if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Create Pull Request Preview\n\n"; - summaryContent += "The following pull request would be created if staged mode was disabled:\n\n"; - summaryContent += `**Title:** ${pullRequestItem.title || "No title provided"}\n\n`; - summaryContent += `**Branch:** ${pullRequestItem.branch || "auto-generated"}\n\n`; - summaryContent += `**Base:** ${baseBranch}\n\n`; - if (pullRequestItem.body) { - summaryContent += `**Body:**\n${pullRequestItem.body}\n\n`; - } - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchStats = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - if (patchStats.trim()) { - summaryContent += `**Changes:** Patch file exists with ${patchStats.split("\n").length} lines\n\n`; - summaryContent += `
Show patch preview\n\n\`\`\`diff\n${patchStats.slice(0, 2000)}${patchStats.length > 2000 ? "\n... (truncated)" : ""}\n\`\`\`\n\n
\n\n`; - } else { - summaryContent += `**Changes:** No changes (empty patch)\n\n`; - } - } - await core.summary.addRaw(summaryContent).write(); - core.info("📝 Pull request creation preview written to step summary"); - return; - } - let title = pullRequestItem.title.trim(); - let processedBody = pullRequestItem.body; - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - let branchName = pullRequestItem.branch ? pullRequestItem.branch.trim() : null; - if (!title) { - title = "Agent Output"; - } - const titlePrefix = process.env.GH_AW_PR_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_PR_EXPIRES", "Pull Request"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - const labelsEnv = process.env.GH_AW_PR_LABELS; - const labels = labelsEnv - ? labelsEnv - .split(",") - .map( label => label.trim()) - .filter( label => label) - : []; - const draftEnv = process.env.GH_AW_PR_DRAFT; - const draft = draftEnv ? draftEnv.toLowerCase() === "true" : true; - core.info(`Creating pull request with title: ${title}`); - core.info(`Labels: ${JSON.stringify(labels)}`); - core.info(`Draft: ${draft}`); - core.info(`Body length: ${body.length}`); - const randomHex = crypto.randomBytes(8).toString("hex"); - if (!branchName) { - core.info("No branch name provided in JSONL, generating unique branch name"); - branchName = `${workflowId}-${randomHex}`; - } else { - branchName = `${branchName}-${randomHex}`; - core.info(`Using branch name from JSONL with added salt: ${branchName}`); - } - core.info(`Generated branch name: ${branchName}`); - core.info(`Base branch: ${baseBranch}`); - core.info(`Fetching latest changes and checking out base branch: ${baseBranch}`); - await exec.exec("git fetch origin"); - await exec.exec(`git checkout ${baseBranch}`); - core.info(`Branch should not exist locally, creating new branch from base: ${branchName}`); - await exec.exec(`git checkout -b ${branchName}`); - core.info(`Created new branch from base: ${branchName}`); - if (!isEmpty) { - core.info("Applying patch..."); - const patchLines = patchContent.split("\n"); - const previewLineCount = Math.min(500, patchLines.length); - core.info(`Patch preview (first ${previewLineCount} of ${patchLines.length} lines):`); - for (let i = 0; i < previewLineCount; i++) { - core.info(patchLines[i]); - } - try { - await exec.exec("git am /tmp/gh-aw/aw.patch"); - core.info("Patch applied successfully"); - } catch (patchError) { - core.error(`Failed to apply patch: ${patchError instanceof Error ? patchError.message : String(patchError)}`); - try { - core.info("Investigating patch failure..."); - const statusResult = await exec.getExecOutput("git", ["status"]); - core.info("Git status output:"); - core.info(statusResult.stdout); - const patchResult = await exec.getExecOutput("git", ["am", "--show-current-patch=diff"]); - core.info("Failed patch content:"); - core.info(patchResult.stdout); - } catch (investigateError) { - core.warning(`Failed to investigate patch failure: ${investigateError instanceof Error ? investigateError.message : String(investigateError)}`); - } - core.setFailed("Failed to apply patch"); - return; - } - try { - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Changes pushed to branch"); - } catch (pushError) { - core.error(`Git push failed: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - core.warning("Git push operation failed - creating fallback issue instead of pull request"); - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - > [!NOTE] - > This was originally intended as a pull request, but the git push operation failed. - > - > **Workflow Run:** [View run details and download patch artifact](${runUrl}) - > - > The patch file is available as an artifact (\`aw.patch\`) in the workflow run linked above. - To apply the patch locally: - \`\`\`sh - # Download the artifact from the workflow run ${runUrl} - # (Use GitHub MCP tools if gh CLI is not available) - gh run download ${runId} -n aw.patch - # Apply the patch - git am aw.patch - \`\`\` - ${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - core.setOutput("push_failed", "true"); - await core.summary - .addRaw( - ` - ## Push Failure Fallback - - **Push Error:** ${pushError instanceof Error ? pushError.message : String(pushError)} - - **Fallback Issue:** [#${issue.number}](${issue.html_url}) - - **Patch Artifact:** Available in workflow run artifacts - - **Note:** Push failed, created issue as fallback - ` - ) - .write(); - return; - } catch (issueError) { - core.setFailed( - `Failed to push and failed to create fallback issue. Push error: ${pushError instanceof Error ? pushError.message : String(pushError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}` - ); - return; - } - } - } else { - core.info("Skipping patch application (empty patch)"); - if (allowEmpty) { - core.info("allow-empty is enabled - will create branch and push with empty commit"); - try { - await exec.exec(`git commit --allow-empty -m "Initialize"`); - core.info("Created empty commit"); - let remoteBranchExists = false; - try { - const { stdout } = await exec.getExecOutput(`git ls-remote --heads origin ${branchName}`); - if (stdout.trim()) { - remoteBranchExists = true; - } - } catch (checkError) { - core.info(`Remote branch check failed (non-fatal): ${checkError instanceof Error ? checkError.message : String(checkError)}`); - } - if (remoteBranchExists) { - core.warning(`Remote branch ${branchName} already exists - appending random suffix`); - const extraHex = crypto.randomBytes(4).toString("hex"); - const oldBranch = branchName; - branchName = `${branchName}-${extraHex}`; - await exec.exec(`git branch -m ${oldBranch} ${branchName}`); - core.info(`Renamed branch to ${branchName}`); - } - await exec.exec(`git push origin ${branchName}`); - core.info("Empty branch pushed successfully"); - } catch (pushError) { - core.setFailed(`Failed to push empty branch: ${pushError instanceof Error ? pushError.message : String(pushError)}`); - return; - } - } else { - const message = "No changes to apply - noop operation completed successfully"; - switch (ifNoChanges) { - case "error": - throw new Error("No changes to apply - failing as configured by if-no-changes: error"); - case "ignore": - return; - case "warn": - default: - core.warning(message); - return; - } - } - } - try { - const { data: pullRequest } = await github.rest.pulls.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - head: branchName, - base: baseBranch, - draft: draft, - }); - core.info(`Created pull request #${pullRequest.number}: ${pullRequest.html_url}`); - if (labels.length > 0) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pullRequest.number, - labels: labels, - }); - core.info(`Added labels to pull request: ${JSON.stringify(labels)}`); - } - core.setOutput("pull_request_number", pullRequest.number); - core.setOutput("pull_request_url", pullRequest.html_url); - core.setOutput("branch_name", branchName); - await updateActivationComment(github, context, core, pullRequest.html_url, pullRequest.number); - await core.summary - .addRaw( - ` - ## Pull Request - - **Pull Request**: [#${pullRequest.number}](${pullRequest.html_url}) - - **Branch**: \`${branchName}\` - - **Base Branch**: \`${baseBranch}\` - ` - ) - .write(); - } catch (prError) { - core.warning(`Failed to create pull request: ${prError instanceof Error ? prError.message : String(prError)}`); - core.info("Falling back to creating an issue instead"); - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const branchUrl = context.payload.repository ? `${context.payload.repository.html_url}/tree/${branchName}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/tree/${branchName}`; - let patchPreview = ""; - if (fs.existsSync("/tmp/gh-aw/aw.patch")) { - const patchContent = fs.readFileSync("/tmp/gh-aw/aw.patch", "utf8"); - patchPreview = generatePatchPreview(patchContent); - } - const fallbackBody = `${body} - --- - **Note:** This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch [\`${branchName}\`](${branchUrl}). - **Original error:** ${prError instanceof Error ? prError.message : String(prError)} - You can manually create a pull request from the branch if needed.${patchPreview}`; - try { - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: fallbackBody, - labels: labels, - }); - core.info(`Created fallback issue #${issue.number}: ${issue.html_url}`); - await updateActivationComment(github, context, core, issue.html_url, issue.number, "issue"); - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - core.setOutput("branch_name", branchName); - core.setOutput("fallback_used", "true"); - await core.summary - .addRaw( - ` - ## Fallback Issue Created - - **Issue**: [#${issue.number}](${issue.html_url}) - - **Branch**: [\`${branchName}\`](${branchUrl}) - - **Base Branch**: \`${baseBranch}\` - - **Note**: Pull request creation failed, created issue as fallback - ` - ) - .write(); - } catch (issueError) { - core.setFailed(`Failed to create both pull request and fallback issue. PR error: ${prError instanceof Error ? prError.message : String(prError)}. Issue error: ${issueError instanceof Error ? issueError.message : String(issueError)}`); - return; - } - } - } - (async () => { await main(); })(); - - name: Add Comment - id: add_comment - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'add_comment')) + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_CREATED_PULL_REQUEST_URL: ${{ steps.create_pull_request.outputs.pull_request_url }} - GH_AW_CREATED_PULL_REQUEST_NUMBER: ${{ steps.create_pull_request.outputs.pull_request_number }} - GH_AW_WORKFLOW_NAME: "Documentation Unbloat" - GH_AW_ENGINE_ID: "claude" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🗜️ *Compressed by [{workflow_name}]({run_url})*\",\"runStarted\":\"📦 Time to slim down! [{workflow_name}]({run_url}) is trimming the excess from this {event_type}...\",\"runSuccess\":\"🗜️ Docs on a diet! [{workflow_name}]({run_url}) has removed the bloat. Lean and mean! 💪\",\"runFailure\":\"📦 Unbloating paused! [{workflow_name}]({run_url}) {status}. The docs remain... fluffy.\"}" - with: + GH_AW_PR_LABELS: "documentation,automation" + GH_AW_PR_TITLE_PREFIX: "[docs] " +GH_AW_PR_DRAFT=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateFooterWithMessages } = require('/tmp/gh-aw/scripts/messages_footer.cjs'); - const { getRepositoryUrl } = require('/tmp/gh-aw/scripts/get_repository_url.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - async function minimizeComment(github, nodeId, reason = "outdated") { - const query = ` - mutation ($nodeId: ID!, $classifier: ReportedContentClassifiers!) { - minimizeComment(input: { subjectId: $nodeId, classifier: $classifier }) { - minimizedComment { - isMinimized - } - } - } - `; - const result = await github.graphql(query, { nodeId, classifier: reason }); - return { - id: nodeId, - isMinimized: result.minimizeComment.minimizedComment.isMinimized, - }; - } - async function findCommentsWithTrackerId(github, owner, repo, issueNumber, workflowId) { - const comments = []; - let page = 1; - const perPage = 100; - while (true) { - const { data } = await github.rest.issues.listComments({ - owner, - repo, - issue_number: issueNumber, - per_page: perPage, - page, - }); - if (data.length === 0) { - break; - } - const filteredComments = data.filter(comment => comment.body?.includes(``) && !comment.body.includes(``)).map(({ id, node_id, body }) => ({ id, node_id, body })); - comments.push(...filteredComments); - if (data.length < perPage) { - break; - } - page++; - } - return comments; - } - async function findDiscussionCommentsWithTrackerId(github, owner, repo, discussionNumber, workflowId) { - const query = ` - query ($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - comments(first: 100, after: $cursor) { - nodes { - id - body - } - pageInfo { - hasNextPage - endCursor - } - } - } - } - } - `; - const comments = []; - let cursor = null; - while (true) { - const result = await github.graphql(query, { owner, repo, num: discussionNumber, cursor }); - if (!result.repository?.discussion?.comments?.nodes) { - break; - } - const filteredComments = result.repository.discussion.comments.nodes - .filter(comment => comment.body?.includes(``) && !comment.body.includes(``)) - .map(({ id, body }) => ({ id, body })); - comments.push(...filteredComments); - if (!result.repository.discussion.comments.pageInfo.hasNextPage) { - break; - } - cursor = result.repository.discussion.comments.pageInfo.endCursor; - } - return comments; - } - async function hideOlderComments(github, owner, repo, itemNumber, workflowId, isDiscussion, reason = "outdated", allowedReasons = null) { - if (!workflowId) { - core.info("No workflow ID available, skipping hide-older-comments"); - return 0; - } - const normalizedReason = reason.toUpperCase(); - if (allowedReasons && allowedReasons.length > 0) { - const normalizedAllowedReasons = allowedReasons.map(r => r.toUpperCase()); - if (!normalizedAllowedReasons.includes(normalizedReason)) { - core.warning(`Reason "${reason}" is not in allowed-reasons list [${allowedReasons.join(", ")}]. Skipping hide-older-comments.`); - return 0; - } - } - core.info(`Searching for previous comments with workflow ID: ${workflowId}`); - let comments; - if (isDiscussion) { - comments = await findDiscussionCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } else { - comments = await findCommentsWithTrackerId(github, owner, repo, itemNumber, workflowId); - } - if (comments.length === 0) { - core.info("No previous comments found with matching workflow ID"); - return 0; - } - core.info(`Found ${comments.length} previous comment(s) to hide with reason: ${normalizedReason}`); - let hiddenCount = 0; - for (const comment of comments) { - const nodeId = isDiscussion ? String(comment.id) : comment.node_id; - core.info(`Hiding comment: ${nodeId}`); - const result = await minimizeComment(github, nodeId, normalizedReason); - hiddenCount++; - core.info(`✓ Hidden comment: ${nodeId}`); - } - core.info(`Successfully hidden ${hiddenCount} comment(s)`); - return hiddenCount; - } - async function commentOnDiscussion(github, owner, repo, discussionNumber, message, replyToId) { - const { repository } = await github.graphql( - ` - query($owner: String!, $repo: String!, $num: Int!) { - repository(owner: $owner, name: $repo) { - discussion(number: $num) { - id - url - } - } - }`, - { owner, repo, num: discussionNumber } - ); - if (!repository || !repository.discussion) { - throw new Error(`Discussion #${discussionNumber} not found in ${owner}/${repo}`); - } - const discussionId = repository.discussion.id; - const discussionUrl = repository.discussion.url; - const mutation = replyToId - ? `mutation($dId: ID!, $body: String!, $replyToId: ID!) { - addDiscussionComment(input: { discussionId: $dId, body: $body, replyToId: $replyToId }) { - comment { - id - body - createdAt - url - } - } - }` - : `mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - body - createdAt - url - } - } - }`; - const variables = replyToId ? { dId: discussionId, body: message, replyToId } : { dId: discussionId, body: message }; - const result = await github.graphql(mutation, variables); - const comment = result.addDiscussionComment.comment; - return { - id: comment.id, - html_url: comment.url, - discussion_url: discussionUrl, - }; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const isDiscussionExplicit = process.env.GITHUB_AW_COMMENT_DISCUSSION === "true"; - const hideOlderCommentsEnabled = process.env.GH_AW_HIDE_OLDER_COMMENTS === "true"; - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const commentItems = result.items.filter( item => item.type === "add_comment"); - if (commentItems.length === 0) { - core.info("No add-comment items found in agent output"); - return; - } - core.info(`Found ${commentItems.length} add-comment item(s)`); - function getTargetNumber(item) { - return item.item_number; - } - const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; - core.info(`Comment target configuration: ${commentTarget}`); - const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; - const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; - const isDiscussion = isDiscussionContext || isDiscussionExplicit; - const workflowId = process.env.GITHUB_WORKFLOW || ""; - const allowedReasons = process.env.GH_AW_ALLOWED_REASONS - ? (() => { - try { - const parsed = JSON.parse(process.env.GH_AW_ALLOWED_REASONS); - core.info(`Allowed reasons for hiding: [${parsed.join(", ")}]`); - return parsed; - } catch (error) { - core.warning(`Failed to parse GH_AW_ALLOWED_REASONS: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - })() - : null; - if (hideOlderCommentsEnabled) { - core.info(`Hide-older-comments is enabled with workflow ID: ${workflowId || "(none)"}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - let summaryContent = "## 🎭 Staged Mode: Add Comments Preview\n\n"; - summaryContent += "The following comments would be added if staged mode was disabled:\n\n"; - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - if (createdIssueUrl || createdDiscussionUrl || createdPullRequestUrl) { - summaryContent += "#### Related Items\n\n"; - if (createdIssueUrl && createdIssueNumber) { - summaryContent += `- Issue: [#${createdIssueNumber}](${createdIssueUrl})\n`; - } - if (createdDiscussionUrl && createdDiscussionNumber) { - summaryContent += `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})\n`; - } - if (createdPullRequestUrl && createdPullRequestNumber) { - summaryContent += `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})\n`; - } - summaryContent += "\n"; - } - for (let i = 0; i < commentItems.length; i++) { - const item = commentItems[i]; - summaryContent += `### Comment ${i + 1}\n`; - const targetNumber = getTargetNumber(item); - if (targetNumber) { - const repoUrl = getRepositoryUrl(); - if (isDiscussion) { - const discussionUrl = `${repoUrl}/discussions/${targetNumber}`; - summaryContent += `**Target Discussion:** [#${targetNumber}](${discussionUrl})\n\n`; - } else { - const issueUrl = `${repoUrl}/issues/${targetNumber}`; - summaryContent += `**Target Issue:** [#${targetNumber}](${issueUrl})\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; } - } else { - if (isDiscussion) { - summaryContent += `**Target:** Current discussion\n\n`; - } else { - summaryContent += `**Target:** Current issue/PR\n\n`; + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += `**Body:**\n${item.body || "No content provided"}\n\n`; - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Comment creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - if (commentTarget === "triggering" && !isIssueContext && !isPRContext && !isDiscussionContext) { - core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - return; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); } - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const createdComments = []; - for (let i = 0; i < commentItems.length; i++) { - const commentItem = commentItems[i]; - core.info(`Processing add-comment item ${i + 1}/${commentItems.length}: bodyLength=${commentItem.body.length}`); - let itemNumber; - let commentEndpoint; - if (commentTarget === "*") { - const targetNumber = getTargetNumber(commentItem); - if (targetNumber) { - itemNumber = parseInt(targetNumber, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number specified: ${targetNumber}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - core.info(`Target is "*" but no number specified in comment item`); - continue; - } - } else if (commentTarget && commentTarget !== "triggering") { - itemNumber = parseInt(commentTarget, 10); - if (isNaN(itemNumber) || itemNumber <= 0) { - core.info(`Invalid target number in target configuration: ${commentTarget}`); - continue; - } - commentEndpoint = isDiscussion ? "discussions" : "issues"; - } else { - if (isIssueContext) { - itemNumber = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; - if (context.payload.issue) { - commentEndpoint = "issues"; - } else { - core.info("Issue context detected but no issue found in payload"); - continue; - } - } else if (isPRContext) { - itemNumber = context.payload.pull_request?.number || context.payload.issue?.number || context.payload.discussion?.number; - if (context.payload.pull_request) { - commentEndpoint = "issues"; - } else { - core.info("Pull request context detected but no pull request found in payload"); - continue; - } - } else if (isDiscussionContext) { - itemNumber = context.payload.discussion?.number || context.payload.issue?.number || context.payload.pull_request?.number; - if (context.payload.discussion) { - commentEndpoint = "discussions"; - } else { - core.info("Discussion context detected but no discussion found in payload"); - continue; + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } } - if (!itemNumber) { - core.info("Could not determine issue, pull request, or discussion number"); - continue; - } - let body = replaceTemporaryIdReferences(commentItem.body.trim(), temporaryIdMap); - const createdIssueUrl = process.env.GH_AW_CREATED_ISSUE_URL; - const createdIssueNumber = process.env.GH_AW_CREATED_ISSUE_NUMBER; - const createdDiscussionUrl = process.env.GH_AW_CREATED_DISCUSSION_URL; - const createdDiscussionNumber = process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - const createdPullRequestUrl = process.env.GH_AW_CREATED_PULL_REQUEST_URL; - const createdPullRequestNumber = process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - const references = [ - createdIssueUrl && createdIssueNumber && `- Issue: [#${createdIssueNumber}](${createdIssueUrl})`, - createdDiscussionUrl && createdDiscussionNumber && `- Discussion: [#${createdDiscussionNumber}](${createdDiscussionUrl})`, - createdPullRequestUrl && createdPullRequestNumber && `- Pull Request: [#${createdPullRequestNumber}](${createdPullRequestUrl})`, - ].filter(Boolean); - if (references.length > 0) { - body += `\n\n#### Related Items\n\n${references.join("\n")}\n`; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - if (workflowId) { - body += `\n\n`; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - body += trackerIDComment; - } - body += `\n\n`; - body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); - if (hideOlderCommentsEnabled && workflowId) { - core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); - } - let comment; - if (commentEndpoint === "discussions") { - core.info(`Creating comment on discussion #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const replyToId = context.eventName === "discussion_comment" && context.payload?.comment?.node_id ? context.payload.comment.node_id : undefined; - if (replyToId) { - core.info(`Creating threaded reply to comment ${replyToId}`); + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } - comment = await commentOnDiscussion(github, context.repo.owner, context.repo.repo, itemNumber, body, replyToId); - core.info("Created discussion comment #" + comment.id + ": " + comment.html_url); - comment.discussion_url = comment.discussion_url; - } else { - core.info(`Creating comment on ${commentEndpoint} #${itemNumber}`); - core.info(`Comment content length: ${body.length}`); - const { data: restComment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - body: body, - }); - comment = restComment; - core.info("Created comment #" + comment.id + ": " + comment.html_url); } - createdComments.push(comment); - if (i === commentItems.length - 1) { - core.setOutput("comment_id", comment.id); - core.setOutput("comment_url", comment.html_url); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } } - } - if (createdComments.length > 0) { - const summaryContent = "\n\n## GitHub Comments\n" + createdComments.map(c => `- Comment #${c.id}: [View Comment](${c.html_url})`).join("\n"); await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdComments.length} comment(s)`); - return createdComments; - } - (async () => { await main(); })(); - - name: Upload Assets - id: upload_assets - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'upload_asset')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Documentation Unbloat" - GH_AW_ENGINE_ID: "claude" - GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🗜️ *Compressed by [{workflow_name}]({run_url})*\",\"runStarted\":\"📦 Time to slim down! [{workflow_name}]({run_url}) is trimming the excess from this {event_type}...\",\"runSuccess\":\"🗜️ Docs on a diet! [{workflow_name}]({run_url}) has removed the bloat. Lean and mean! 💪\",\"runFailure\":\"📦 Unbloating paused! [{workflow_name}]({run_url}) {status}. The docs remain... fluffy.\"}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const path = require("path"); - const crypto = require("crypto"); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function normalizeBranchName(branchName) { - if (!branchName || typeof branchName !== "string" || branchName.trim() === "") { - return branchName; - } - let normalized = branchName.replace(/[^a-zA-Z0-9\-_/.]+/g, "-"); - normalized = normalized.replace(/-+/g, "-"); - normalized = normalized.replace(/^-+|-+$/g, ""); - if (normalized.length > 128) { - normalized = normalized.substring(0, 128); - } - normalized = normalized.replace(/-+$/, ""); - normalized = normalized.toLowerCase(); - return normalized; + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const branchName = process.env.GH_AW_ASSETS_BRANCH; - if (!branchName || typeof branchName !== "string") { - core.setFailed("GH_AW_ASSETS_BRANCH environment variable is required but not set"); - return; - } - const normalizedBranchName = normalizeBranchName(branchName); - core.info(`Using assets branch: ${normalizedBranchName}`); - const result = loadAgentOutput(); - if (!result.success) { - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - const uploadItems = result.items.filter( item => item.type === "upload_assets"); - const uploadAssetItems = result.items.filter( item => item.type === "upload_asset"); - const allUploadItems = [...uploadItems, ...uploadAssetItems]; - if (allUploadItems.length === 0) { - core.info("No upload-asset items found in agent output"); - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - core.info(`Found ${allUploadItems.length} upload-asset item(s)`); - let uploadCount = 0; - let hasChanges = false; + (async () => { try { - try { - await exec.exec(`git rev-parse --verify origin/${normalizedBranchName}`); - await exec.exec(`git checkout -B ${normalizedBranchName} origin/${normalizedBranchName}`); - core.info(`Checked out existing branch from origin: ${normalizedBranchName}`); - } catch (originError) { - if (!normalizedBranchName.startsWith("assets/")) { - core.setFailed( - `Branch '${normalizedBranchName}' does not start with the required 'assets/' prefix. ` + - `Orphaned branches can only be automatically created under the 'assets/' prefix. ` + - `Please create the branch manually first, or use a branch name starting with 'assets/'.` - ); - return; - } - core.info(`Creating new orphaned branch: ${normalizedBranchName}`); - await exec.exec(`git checkout --orphan ${normalizedBranchName}`); - await exec.exec(`git rm -rf .`); - await exec.exec(`git clean -fdx`); - } - for (const asset of uploadAssetItems) { - try { - const { fileName, sha, size, targetFileName } = asset; - if (!fileName || !sha || !targetFileName) { - core.error(`Invalid asset entry missing required fields: ${JSON.stringify(asset)}`); - continue; - } - const assetSourcePath = path.join("/tmp/gh-aw/safeoutputs/assets", fileName); - if (!fs.existsSync(assetSourcePath)) { - core.warning(`Asset file not found: ${assetSourcePath}`); - continue; - } - const fileContent = fs.readFileSync(assetSourcePath); - const computedSha = crypto.createHash("sha256").update(fileContent).digest("hex"); - if (computedSha !== sha) { - core.warning(`SHA mismatch for ${fileName}: expected ${sha}, got ${computedSha}`); - continue; - } - if (fs.existsSync(targetFileName)) { - core.info(`Asset ${targetFileName} already exists, skipping`); - continue; - } - fs.copyFileSync(assetSourcePath, targetFileName); - await exec.exec(`git add "${targetFileName}"`); - uploadCount++; - hasChanges = true; - core.info(`Added asset: ${targetFileName} (${size} bytes)`); - } catch (error) { - core.warning(`Failed to process asset ${asset.fileName}: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (hasChanges) { - const commitMessage = `[skip-ci] Add ${uploadCount} asset(s)`; - await exec.exec(`git`, [`commit`, `-m`, commitMessage]); - if (isStaged) { - core.summary.addRaw("## Staged Asset Publication"); - } else { - await exec.exec(`git push origin ${normalizedBranchName}`); - core.summary.addRaw("## Assets").addRaw(`Successfully uploaded **${uploadCount}** assets to branch \`${normalizedBranchName}\``).addRaw(""); - core.info(`Successfully uploaded ${uploadCount} assets to branch ${normalizedBranchName}`); - } - for (const asset of uploadAssetItems) { - if (asset.fileName && asset.sha && asset.size && asset.url) { - core.summary.addRaw(`- [\`${asset.fileName}\`](${asset.url}) → \`${asset.targetFileName}\` (${asset.size} bytes)`); - } - } - core.summary.write(); - } else { - core.info("No new assets to upload"); - } + await main(); } catch (error) { - core.setFailed(`Failed to upload assets: ${error instanceof Error ? error.message : String(error)}`); - return; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - core.setOutput("upload_count", uploadCount.toString()); - core.setOutput("branch_name", normalizedBranchName); - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/.github/workflows/video-analyzer.lock.yml b/.github/workflows/video-analyzer.lock.yml index 241d38c460..fc09626ac4 100644 --- a/.github/workflows/video-analyzer.lock.yml +++ b/.github/workflows/video-analyzer.lock.yml @@ -6787,9 +6787,16 @@ jobs: issues: write timeout-minutes: 15 outputs: - create_issue_issue_number: ${{ steps.create_issue.outputs.issue_number }} - create_issue_issue_url: ${{ steps.create_issue.outputs.issue_url }} - create_issue_temporary_id_map: ${{ steps.create_issue.outputs.temporary_id_map }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -6802,944 +6809,177 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/generate_footer.cjs << 'EOF_88f9d2d4' - // @ts-check - /// - - /** - * Generates an XML comment marker with agentic workflow metadata for traceability. - * This marker enables searching and tracing back items generated by an agentic workflow. - * - * Note: This function is duplicated in messages_footer.cjs. While normally we would - * consolidate to a shared module, importing messages_footer.cjs here would cause the - * bundler to inline messages_core.cjs which contains 'GH_AW_SAFE_OUTPUT_MESSAGES:' in - * a warning message, breaking tests that check for env var declarations. - * - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {string} XML comment marker with workflow metadata - */ - function generateXMLMarker(workflowName, runUrl) { - // Read engine metadata from environment variables - const engineId = process.env.GH_AW_ENGINE_ID || ""; - const engineVersion = process.env.GH_AW_ENGINE_VERSION || ""; - const engineModel = process.env.GH_AW_ENGINE_MODEL || ""; - const trackerId = process.env.GH_AW_TRACKER_ID || ""; - - // Build the key-value pairs for the marker - const parts = []; - - // Always include agentic-workflow name - parts.push(`agentic-workflow: ${workflowName}`); - - // Add tracker-id if available (for searchability and tracing) - if (trackerId) { - parts.push(`tracker-id: ${trackerId}`); - } - - // Add engine ID if available - if (engineId) { - parts.push(`engine: ${engineId}`); - } - - // Add version if available - if (engineVersion) { - parts.push(`version: ${engineVersion}`); - } - - // Add model if available - if (engineModel) { - parts.push(`model: ${engineModel}`); - } - - // Always include run URL - parts.push(`run: ${runUrl}`); - - // Return the XML comment marker - return ``; - } - - /** - * Generate footer with AI attribution and workflow installation instructions - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @param {string} workflowSource - Source of the workflow (owner/repo/path@ref) - * @param {string} workflowSourceURL - GitHub URL for the workflow source - * @param {number|undefined} triggeringIssueNumber - Issue number that triggered this workflow - * @param {number|undefined} triggeringPRNumber - Pull request number that triggered this workflow - * @param {number|undefined} triggeringDiscussionNumber - Discussion number that triggered this workflow - * @returns {string} Footer text - */ - function generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { - let footer = `\n\n> AI generated by [${workflowName}](${runUrl})`; - - // Add reference to triggering issue/PR/discussion if available - if (triggeringIssueNumber) { - footer += ` for #${triggeringIssueNumber}`; - } else if (triggeringPRNumber) { - footer += ` for #${triggeringPRNumber}`; - } else if (triggeringDiscussionNumber) { - footer += ` for discussion #${triggeringDiscussionNumber}`; - } - - if (workflowSource && workflowSourceURL) { - footer += `\n>\n> To add this workflow in your repository, run \`gh aw add ${workflowSource}\`. See [usage guide](https://githubnext.github.io/gh-aw/tools/cli/).`; - } - - // Add XML comment marker for traceability - footer += "\n\n" + generateXMLMarker(workflowName, runUrl); - - footer += "\n"; - return footer; - } - - module.exports = { - generateFooter, - generateXMLMarker, - }; - - EOF_88f9d2d4 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/sanitize_label_content.cjs << 'EOF_4b431e5e' - // @ts-check - /** - * Sanitize label content for GitHub API - * Removes control characters, ANSI codes, and neutralizes @mentions - * @module sanitize_label_content - */ - - /** - * Sanitizes label content by removing control characters, ANSI escape codes, - * and neutralizing @mentions to prevent unintended notifications. - * - * @param {string} content - The label content to sanitize - * @returns {string} The sanitized label content - */ - function sanitizeLabelContent(content) { - if (!content || typeof content !== "string") { - return ""; - } - let sanitized = content.trim(); - // Remove ANSI escape sequences FIRST (before removing control chars) - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); - // Then remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); - sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); - sanitized = sanitized.replace(/[<>&'"]/g, ""); - return sanitized.trim(); - } - - module.exports = { sanitizeLabelContent }; - - EOF_4b431e5e - cat > /tmp/gh-aw/scripts/staged_preview.cjs << 'EOF_8386ee20' - // @ts-check - /// - - /** - * Generate a staged mode preview summary and write it to the step summary. - * - * @param {Object} options - Configuration options for the preview - * @param {string} options.title - The main title for the preview (e.g., "Create Issues") - * @param {string} options.description - Description of what would happen if staged mode was disabled - * @param {Array} options.items - Array of items to preview - * @param {(item: any, index: number) => string} options.renderItem - Function to render each item as markdown - * @returns {Promise} - */ - async function generateStagedPreview(options) { - const { title, description, items, renderItem } = options; - - let summaryContent = `## 🎭 Staged Mode: ${title} Preview\n\n`; - summaryContent += `${description}\n\n`; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - summaryContent += renderItem(item, i); - summaryContent += "---\n\n"; - } - - try { - await core.summary.addRaw(summaryContent).write(); - core.info(summaryContent); - core.info(`📝 ${title} preview written to step summary`); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - - module.exports = { generateStagedPreview }; - - EOF_8386ee20 - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Issue - id: create_issue - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_issue')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_ISSUE_TITLE_PREFIX: "[video-analysis] " GH_AW_ISSUE_LABELS: "automation,video-processing" - GH_AW_WORKFLOW_NAME: "Video Analysis Agent" - GH_AW_ENGINE_ID: "copilot" with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { sanitizeLabelContent } = require('/tmp/gh-aw/scripts/sanitize_label_content.cjs'); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { generateStagedPreview } = require('/tmp/gh-aw/scripts/staged_preview.cjs'); - const { generateFooter } = require('/tmp/gh-aw/scripts/generate_footer.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { core.setOutput("issue_number", ""); core.setOutput("issue_url", ""); core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createIssueItems = result.items.filter(item => item.type === "create_issue"); - if (createIssueItems.length === 0) { - core.info("No create-issue items found in agent output"); - return; - } - core.info(`Found ${createIssueItems.length} create-issue item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); if (isStaged) { - await generateStagedPreview({ - title: "Create Issues", - description: "The following issues would be created if staged mode was disabled:", - items: createIssueItems, - renderItem: (item, index) => { - let content = `#### Issue ${index + 1}\n`; - content += `**Title:** ${item.title || "No title provided"}\n\n`; + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } if (item.temporary_id) { - content += `**Temporary ID:** ${item.temporary_id}\n\n`; + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; } if (item.repo) { - content += `**Repository:** ${item.repo}\n\n`; + summaryContent += `**Repository:** ${item.repo}\n\n`; } if (item.body) { - content += `**Body:**\n${item.body}\n\n`; - } - if (item.labels && item.labels.length > 0) { - content += `**Labels:** ${item.labels.join(", ")}\n\n`; + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; } - if (item.parent) { - content += `**Parent:** ${item.parent}\n\n`; + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; } - return content; - }, - }); + summaryContent += "---\n\n"; + } + } + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); return; } - const parentIssueNumber = context.payload?.issue?.number; - const temporaryIdMap = new Map(); - const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); - const triggeringDiscussionNumber = context.payload?.discussion?.number; - const labelsEnv = process.env.GH_AW_ISSUE_LABELS; - let envLabels = labelsEnv - ? labelsEnv - .split(",") - .map(label => label.trim()) - .filter(label => label) - : []; - const createdIssues = []; - for (let i = 0; i < createIssueItems.length; i++) { - const createIssueItem = createIssueItems[i]; - const itemRepo = createIssueItem.repo ? String(createIssueItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping issue: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); } - const temporaryId = createIssueItem.temporary_id || generateTemporaryId(); - core.info(`Processing create-issue item ${i + 1}/${createIssueItems.length}: title=${createIssueItem.title}, bodyLength=${createIssueItem.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); - core.info(`Debug: createIssueItem.parent = ${JSON.stringify(createIssueItem.parent)}`); - core.info(`Debug: parentIssueNumber from context = ${JSON.stringify(parentIssueNumber)}`); - let effectiveParentIssueNumber; - let effectiveParentRepo = itemRepo; - if (createIssueItem.parent !== undefined) { - if (isTemporaryId(createIssueItem.parent)) { - const resolvedParent = temporaryIdMap.get(normalizeTemporaryId(createIssueItem.parent)); - if (resolvedParent !== undefined) { - effectiveParentIssueNumber = resolvedParent.number; - effectiveParentRepo = resolvedParent.repo; - core.info(`Resolved parent temporary ID '${createIssueItem.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } else { - core.warning(`Parent temporary ID '${createIssueItem.parent}' not found in map. Ensure parent issue is created before sub-issues.`); - effectiveParentIssueNumber = undefined; - } - } else { - effectiveParentIssueNumber = parseInt(String(createIssueItem.parent), 10); - if (isNaN(effectiveParentIssueNumber)) { - core.warning(`Invalid parent value: ${createIssueItem.parent}`); - effectiveParentIssueNumber = undefined; + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } } - } else { - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - if (itemRepo === contextRepo) { - effectiveParentIssueNumber = parentIssueNumber; - } } - core.info(`Debug: effectiveParentIssueNumber = ${JSON.stringify(effectiveParentIssueNumber)}, effectiveParentRepo = ${effectiveParentRepo}`); - if (effectiveParentIssueNumber && createIssueItem.parent !== undefined) { - core.info(`Using explicit parent issue number from item: ${effectiveParentRepo}#${effectiveParentIssueNumber}`); - } - let labels = [...envLabels]; - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels]; - } - labels = labels - .filter(label => !!label) - .map(label => String(label).trim()) - .filter(label => label) - .map(label => sanitizeLabelContent(label)) - .filter(label => label) - .map(label => (label.length > 64 ? label.substring(0, 64) : label)) - .filter((label, index, arr) => arr.indexOf(label) === index); - let title = createIssueItem.title ? createIssueItem.title.trim() : ""; - let processedBody = replaceTemporaryIdReferences(createIssueItem.body, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = createIssueItem.body || "Agent Output"; - } - const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (effectiveParentIssueNumber) { - core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); - if (effectiveParentRepo === itemRepo) { - bodyLines.push(`Related to #${effectiveParentIssueNumber}`); - } else { - bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); } } - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; - const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); - bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating issue in ${itemRepo} with title: ${title}`); - core.info(`Labels: ${labels}`); - core.info(`Body length: ${body.length}`); - try { - const { data: issue } = await github.rest.issues.create({ - owner: repoParts.owner, - repo: repoParts.repo, - title: title, - body: body, - labels: labels, - }); - core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); - createdIssues.push({ ...issue, _repo: itemRepo }); - temporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); - core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); - core.info(`Debug: About to check if sub-issue linking is needed. effectiveParentIssueNumber = ${effectiveParentIssueNumber}`); - if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { - core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); - try { - core.info(`Fetching node ID for parent issue #${effectiveParentIssueNumber}...`); - const getIssueNodeIdQuery = ` - query($owner: String!, $repo: String!, $issueNumber: Int!) { - repository(owner: $owner, name: $repo) { - issue(number: $issueNumber) { - id - } - } - } - `; - const parentResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: effectiveParentIssueNumber, - }); - const parentNodeId = parentResult.repository.issue.id; - core.info(`Parent issue node ID: ${parentNodeId}`); - core.info(`Fetching node ID for child issue #${issue.number}...`); - const childResult = await github.graphql(getIssueNodeIdQuery, { - owner: repoParts.owner, - repo: repoParts.repo, - issueNumber: issue.number, - }); - const childNodeId = childResult.repository.issue.id; - core.info(`Child issue node ID: ${childNodeId}`); - core.info(`Executing addSubIssue mutation...`); - const addSubIssueMutation = ` - mutation($issueId: ID!, $subIssueId: ID!) { - addSubIssue(input: { - issueId: $issueId, - subIssueId: $subIssueId - }) { - subIssue { - id - number - } - } - } - `; - await github.graphql(addSubIssueMutation, { - issueId: parentNodeId, - subIssueId: childNodeId, - }); - core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); - } catch (error) { - core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); - core.info(`Error details: ${error instanceof Error ? error.stack : String(error)}`); - try { - core.info(`Attempting fallback: adding comment to parent issue #${effectiveParentIssueNumber}...`); - await github.rest.issues.createComment({ - owner: repoParts.owner, - repo: repoParts.repo, - issue_number: effectiveParentIssueNumber, - body: `Created related issue: #${issue.number}`, - }); - core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); - } catch (commentError) { - core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); - } - } - } else if (effectiveParentIssueNumber && effectiveParentRepo !== itemRepo) { - core.info(`Skipping sub-issue linking: parent is in different repository (${effectiveParentRepo})`); - } else { - core.info(`Debug: No parent issue number set, skipping sub-issue linking`); + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } - if (i === createIssueItems.length - 1) { - core.setOutput("issue_number", issue.number); - core.setOutput("issue_url", issue.html_url); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Issues has been disabled in this repository")) { - core.info(`⚠ Cannot create issue "${title}" in ${itemRepo}: Issues are disabled for this repository`); - core.info("Consider enabling issues in repository settings if you want to create issues automatically"); - continue; - } - core.error(`✗ Failed to create issue "${title}" in ${itemRepo}: ${errorMessage}`); - throw error; - } - } - if (createdIssues.length > 0) { - let summaryContent = "\n\n## GitHub Issues\n"; - for (const issue of createdIssues) { - const repoLabel = issue._repo !== defaultTargetRepo ? ` (${issue._repo})` : ""; - summaryContent += `- Issue #${issue.number}${repoLabel}: [${issue.title}](${issue.html_url})\n`; } await core.summary.addRaw(summaryContent).write(); } - const tempIdMapOutput = serializeTemporaryIdMap(temporaryIdMap); - core.setOutput("temporary_id_map", tempIdMapOutput); - core.info(`Temporary ID map: ${tempIdMapOutput}`); - const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; - if (assignCopilot && createdIssues.length > 0) { - const issuesToAssign = createdIssues.map(issue => `${issue._repo}:${issue.number}`).join(","); - core.setOutput("issues_to_assign_copilot", issuesToAssign); - core.info(`Issues to assign copilot: ${issuesToAssign}`); - } - core.info(`Successfully created ${createdIssues.length} issue(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } (async () => { - await main(); + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } })(); diff --git a/.github/workflows/weekly-issue-summary.lock.yml b/.github/workflows/weekly-issue-summary.lock.yml index 5178f5ecfd..eeceefbbf9 100644 --- a/.github/workflows/weekly-issue-summary.lock.yml +++ b/.github/workflows/weekly-issue-summary.lock.yml @@ -7209,8 +7209,16 @@ jobs: discussions: write timeout-minutes: 15 outputs: - create_discussion_discussion_number: ${{ steps.create_discussion.outputs.discussion_number }} - create_discussion_discussion_url: ${{ steps.create_discussion.outputs.discussion_url }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + discussion_number: ${{ steps.process_safe_outputs.outputs.discussion_number }} + discussion_url: ${{ steps.process_safe_outputs.outputs.discussion_url }} + issue_number: ${{ steps.process_safe_outputs.outputs.issue_number }} + issue_url: ${{ steps.process_safe_outputs.outputs.issue_url }} + issues_to_assign_copilot: ${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }} + pull_request_number: ${{ steps.process_safe_outputs.outputs.pull_request_number }} + pull_request_url: ${{ steps.process_safe_outputs.outputs.pull_request_url }} + temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: - name: Download agent output artifact continue-on-error: true @@ -7223,1313 +7231,179 @@ jobs: mkdir -p /tmp/gh-aw/safeoutputs/ find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Setup JavaScript files - id: setup_scripts - shell: bash + - name: Download agent output artifact + continue-on-error: true + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: agent_output.json + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable run: | - mkdir -p /tmp/gh-aw/scripts - cat > /tmp/gh-aw/scripts/close_older_discussions.cjs << 'EOF_1a84cdd3' - // @ts-check - /// - - const { getCloseOlderDiscussionMessage } = require('/tmp/gh-aw/scripts/messages_close_discussion.cjs'); - - /** - * Maximum number of older discussions to close - */ - const MAX_CLOSE_COUNT = 10; - - /** - * Delay between GraphQL API calls in milliseconds to avoid rate limiting - */ - const GRAPHQL_DELAY_MS = 500; - - /** - * Delay execution for a specified number of milliseconds - * @param {number} ms - Milliseconds to delay - * @returns {Promise} - */ - function delay(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Search for open discussions with a matching title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip prefix matching) - * @param {string[]} labels - Labels to match (empty array to skip label matching) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {number} excludeNumber - Discussion number to exclude (the newly created one) - * @returns {Promise>} Matching discussions - */ - async function searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, excludeNumber) { - // Build GraphQL search query - // Search for open discussions, optionally with title prefix or labels - let searchQuery = `repo:${owner}/${repo} is:open`; - - if (titlePrefix) { - // Escape quotes in title prefix to prevent query injection - const escapedPrefix = titlePrefix.replace(/"/g, '\\"'); - searchQuery += ` in:title "${escapedPrefix}"`; - } - - // Add label filters to the search query - // Note: GitHub search uses AND logic for multiple labels, so discussions must have ALL labels. - // We add each label as a separate filter and also validate client-side for extra safety. - if (labels && labels.length > 0) { - for (const label of labels) { - // Escape quotes in label names to prevent query injection - const escapedLabel = label.replace(/"/g, '\\"'); - searchQuery += ` label:"${escapedLabel}"`; - } - } - - const result = await github.graphql( - ` - query($searchTerms: String!, $first: Int!) { - search(query: $searchTerms, type: DISCUSSION, first: $first) { - nodes { - ... on Discussion { - id - number - title - url - category { - id - } - labels(first: 100) { - nodes { - name - } - } - closed - } - } - } - }`, - { searchTerms: searchQuery, first: 50 } - ); - - if (!result || !result.search || !result.search.nodes) { - return []; - } - - // Filter results: - // 1. Must not be the excluded discussion (newly created one) - // 2. Must not be already closed - // 3. If titlePrefix is specified, must have title starting with the prefix - // 4. If labels are specified, must have ALL specified labels (AND logic, not OR) - // 5. If categoryId is specified, must match - return result.search.nodes - .filter( - /** @param {any} d */ d => { - if (!d || d.number === excludeNumber || d.closed) { - return false; - } - - // Check title prefix if specified - if (titlePrefix && d.title && !d.title.startsWith(titlePrefix)) { - return false; - } - - // Check labels if specified - requires ALL labels to match (AND logic) - // This is intentional: we only want to close discussions that have ALL the specified labels - if (labels && labels.length > 0) { - const discussionLabels = d.labels?.nodes?.map((/** @type {{name: string}} */ l) => l.name) || []; - const hasAllLabels = labels.every(label => discussionLabels.includes(label)); - if (!hasAllLabels) { - return false; - } - } - - // Check category if specified - if (categoryId && (!d.category || d.category.id !== categoryId)) { - return false; - } - - return true; - } - ) - .map( - /** @param {any} d */ d => ({ - id: d.id, - number: d.number, - title: d.title, - url: d.url, - }) - ); - } - - /** - * Add comment to a GitHub Discussion using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @param {string} message - Comment body - * @returns {Promise<{id: string, url: string}>} Comment details - */ - async function addDiscussionComment(github, discussionId, message) { - const result = await github.graphql( - ` - mutation($dId: ID!, $body: String!) { - addDiscussionComment(input: { discussionId: $dId, body: $body }) { - comment { - id - url - } - } - }`, - { dId: discussionId, body: message } - ); - - return result.addDiscussionComment.comment; - } - - /** - * Close a GitHub Discussion as OUTDATED using GraphQL - * @param {any} github - GitHub GraphQL instance - * @param {string} discussionId - Discussion node ID - * @returns {Promise<{id: string, url: string}>} Discussion details - */ - async function closeDiscussionAsOutdated(github, discussionId) { - const result = await github.graphql( - ` - mutation($dId: ID!) { - closeDiscussion(input: { discussionId: $dId, reason: OUTDATED }) { - discussion { - id - url - } - } - }`, - { dId: discussionId } - ); - - return result.closeDiscussion.discussion; - } - - /** - * Close older discussions that match the title prefix and/or labels - * @param {any} github - GitHub GraphQL instance - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} titlePrefix - Title prefix to match (empty string to skip) - * @param {string[]} labels - Labels to match (empty array to skip) - * @param {string|undefined} categoryId - Optional category ID to filter by - * @param {{number: number, url: string}} newDiscussion - The newly created discussion - * @param {string} workflowName - Name of the workflow - * @param {string} runUrl - URL of the workflow run - * @returns {Promise>} List of closed discussions - */ - async function closeOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion, workflowName, runUrl) { - // Build search criteria description for logging - const searchCriteria = []; - if (titlePrefix) searchCriteria.push(`title prefix: "${titlePrefix}"`); - if (labels && labels.length > 0) searchCriteria.push(`labels: [${labels.join(", ")}]`); - core.info(`Searching for older discussions with ${searchCriteria.join(" and ")}`); - - const olderDiscussions = await searchOlderDiscussions(github, owner, repo, titlePrefix, labels, categoryId, newDiscussion.number); - - if (olderDiscussions.length === 0) { - core.info("No older discussions found to close"); - return []; - } - - core.info(`Found ${olderDiscussions.length} older discussion(s) to close`); - - // Limit to MAX_CLOSE_COUNT discussions - const discussionsToClose = olderDiscussions.slice(0, MAX_CLOSE_COUNT); - - if (olderDiscussions.length > MAX_CLOSE_COUNT) { - core.warning(`Found ${olderDiscussions.length} older discussions, but only closing the first ${MAX_CLOSE_COUNT}`); - } - - const closedDiscussions = []; - - for (let i = 0; i < discussionsToClose.length; i++) { - const discussion = discussionsToClose[i]; - try { - // Generate closing message using the messages module - const closingMessage = getCloseOlderDiscussionMessage({ - newDiscussionUrl: newDiscussion.url, - newDiscussionNumber: newDiscussion.number, - workflowName, - runUrl, - }); - - // Add comment first - core.info(`Adding closing comment to discussion #${discussion.number}`); - await addDiscussionComment(github, discussion.id, closingMessage); - - // Then close the discussion as outdated - core.info(`Closing discussion #${discussion.number} as outdated`); - await closeDiscussionAsOutdated(github, discussion.id); - - closedDiscussions.push({ - number: discussion.number, - url: discussion.url, - }); - - core.info(`✓ Closed discussion #${discussion.number}: ${discussion.url}`); - } catch (error) { - core.error(`✗ Failed to close discussion #${discussion.number}: ${error instanceof Error ? error.message : String(error)}`); - // Continue with other discussions even if one fails - } - - // Add delay between GraphQL operations to avoid rate limiting (except for the last item) - if (i < discussionsToClose.length - 1) { - await delay(GRAPHQL_DELAY_MS); - } - } - - return closedDiscussions; - } - - module.exports = { - closeOlderDiscussions, - searchOlderDiscussions, - addDiscussionComment, - closeDiscussionAsOutdated, - MAX_CLOSE_COUNT, - GRAPHQL_DELAY_MS, - }; - - EOF_1a84cdd3 - cat > /tmp/gh-aw/scripts/expiration_helpers.cjs << 'EOF_33eff070' - // @ts-check - /// - - /** - * Add expiration XML comment to body lines if expires is set - * @param {string[]} bodyLines - Array of body lines to append to - * @param {string} envVarName - Name of the environment variable containing expires days (e.g., "GH_AW_DISCUSSION_EXPIRES") - * @param {string} entityType - Type of entity for logging (e.g., "Discussion", "Issue", "Pull Request") - * @returns {void} - */ - function addExpirationComment(bodyLines, envVarName, entityType) { - const expiresEnv = process.env[envVarName]; - if (expiresEnv) { - const expiresDays = parseInt(expiresEnv, 10); - if (!isNaN(expiresDays) && expiresDays > 0) { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + expiresDays); - const expirationISO = expirationDate.toISOString(); - bodyLines.push(``); - core.info(`${entityType} will expire on ${expirationISO} (${expiresDays} days)`); - } - } - } - - module.exports = { - addExpirationComment, - }; - - EOF_33eff070 - cat > /tmp/gh-aw/scripts/get_tracker_id.cjs << 'EOF_bfad4250' - // @ts-check - /// - - /** - * Get tracker-id from environment variable, log it, and optionally format it - * @param {string} [format] - Output format: "markdown" for HTML comment, "text" for plain text, or undefined for raw value - * @returns {string} Tracker ID in requested format or empty string - */ - function getTrackerID(format) { - const trackerID = process.env.GH_AW_TRACKER_ID || ""; - if (trackerID) { - core.info(`Tracker ID: ${trackerID}`); - return format === "markdown" ? `\n\n` : trackerID; - } - return ""; - } - - module.exports = { - getTrackerID, - }; - - EOF_bfad4250 - cat > /tmp/gh-aw/scripts/load_agent_output.cjs << 'EOF_b93f537f' - // @ts-check - /// - - const fs = require("fs"); - - /** - * Maximum content length to log for debugging purposes - * @type {number} - */ - const MAX_LOG_CONTENT_LENGTH = 10000; - - /** - * Truncate content for logging if it exceeds the maximum length - * @param {string} content - Content to potentially truncate - * @returns {string} Truncated content with indicator if truncated - */ - function truncateForLogging(content) { - if (content.length <= MAX_LOG_CONTENT_LENGTH) { - return content; - } - return content.substring(0, MAX_LOG_CONTENT_LENGTH) + `\n... (truncated, total length: ${content.length})`; - } - - /** - * Load and parse agent output from the GH_AW_AGENT_OUTPUT file - * - * This utility handles the common pattern of: - * 1. Reading the GH_AW_AGENT_OUTPUT environment variable - * 2. Loading the file content - * 3. Validating the JSON structure - * 4. Returning parsed items array - * - * @returns {{ - * success: true, - * items: any[] - * } | { - * success: false, - * items?: undefined, - * error?: string - * }} Result object with success flag and items array (if successful) or error message - */ - function loadAgentOutput() { - const agentOutputFile = process.env.GH_AW_AGENT_OUTPUT; - - // No agent output file specified - if (!agentOutputFile) { - core.info("No GH_AW_AGENT_OUTPUT environment variable found"); - return { success: false }; - } - - // Read agent output from file - let outputContent; - try { - outputContent = fs.readFileSync(agentOutputFile, "utf8"); - } catch (error) { - const errorMessage = `Error reading agent output file: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - return { success: false, error: errorMessage }; - } - - // Check for empty content - if (outputContent.trim() === "") { - core.info("Agent output content is empty"); - return { success: false }; - } - - core.info(`Agent output content length: ${outputContent.length}`); - - // Parse the validated output JSON - let validatedOutput; - try { - validatedOutput = JSON.parse(outputContent); - } catch (error) { - const errorMessage = `Error parsing agent output JSON: ${error instanceof Error ? error.message : String(error)}`; - core.error(errorMessage); - core.info(`Failed to parse content:\n${truncateForLogging(outputContent)}`); - return { success: false, error: errorMessage }; - } - - // Validate items array exists - if (!validatedOutput.items || !Array.isArray(validatedOutput.items)) { - core.info("No valid items found in agent output"); - core.info(`Parsed content: ${truncateForLogging(JSON.stringify(validatedOutput))}`); - return { success: false }; - } - - return { success: true, items: validatedOutput.items }; - } - - module.exports = { loadAgentOutput, truncateForLogging, MAX_LOG_CONTENT_LENGTH }; - - EOF_b93f537f - cat > /tmp/gh-aw/scripts/messages_close_discussion.cjs << 'EOF_2b835e89' - // @ts-check - /// - - /** - * Close Discussion Message Module - * - * This module provides the message for closing older discussions - * when a newer one is created. - */ - - const { getMessages, renderTemplate, toSnakeCase } = require('/tmp/gh-aw/scripts/messages_core.cjs'); - - /** - * @typedef {Object} CloseOlderDiscussionContext - * @property {string} newDiscussionUrl - URL of the new discussion that replaced this one - * @property {number} newDiscussionNumber - Number of the new discussion - * @property {string} workflowName - Name of the workflow - * @property {string} runUrl - URL of the workflow run - */ - - /** - * Get the close-older-discussion message, using custom template if configured. - * @param {CloseOlderDiscussionContext} ctx - Context for message generation - * @returns {string} Close older discussion message - */ - function getCloseOlderDiscussionMessage(ctx) { - const messages = getMessages(); - - // Create context with both camelCase and snake_case keys - const templateContext = toSnakeCase(ctx); - - // Default close-older-discussion template - pirate themed! 🏴‍☠️ - const defaultMessage = `⚓ Avast! This discussion be marked as **outdated** by [{workflow_name}]({run_url}). - - 🗺️ A newer treasure map awaits ye at **[Discussion #{new_discussion_number}]({new_discussion_url})**. - - Fair winds, matey! 🏴‍☠️`; - - // Use custom message if configured - return messages?.closeOlderDiscussion ? renderTemplate(messages.closeOlderDiscussion, templateContext) : renderTemplate(defaultMessage, templateContext); - } - - module.exports = { - getCloseOlderDiscussionMessage, - }; - - EOF_2b835e89 - cat > /tmp/gh-aw/scripts/messages_core.cjs << 'EOF_6cdb27e0' - // @ts-check - /// - - /** - * Core Message Utilities Module - * - * This module provides shared utilities for message template processing. - * It includes configuration parsing and template rendering functions. - * - * Supported placeholders: - * - {workflow_name} - Name of the workflow - * - {run_url} - URL to the workflow run - * - {workflow_source} - Source specification (owner/repo/path@ref) - * - {workflow_source_url} - GitHub URL for the workflow source - * - {triggering_number} - Issue/PR/Discussion number that triggered this workflow - * - {operation} - Operation name (for staged mode titles/descriptions) - * - {event_type} - Event type description (for run-started messages) - * - {status} - Workflow status text (for run-failure messages) - * - * Both camelCase and snake_case placeholder formats are supported. - */ - - /** - * @typedef {Object} SafeOutputMessages - * @property {string} [footer] - Custom footer message template - * @property {string} [footerInstall] - Custom installation instructions template - * @property {string} [stagedTitle] - Custom staged mode title template - * @property {string} [stagedDescription] - Custom staged mode description template - * @property {string} [runStarted] - Custom workflow activation message template - * @property {string} [runSuccess] - Custom workflow success message template - * @property {string} [runFailure] - Custom workflow failure message template - * @property {string} [detectionFailure] - Custom detection job failure message template - * @property {string} [closeOlderDiscussion] - Custom message for closing older discussions as outdated - */ - - /** - * Get the safe-output messages configuration from environment variable. - * @returns {SafeOutputMessages|null} Parsed messages config or null if not set - */ - function getMessages() { - const messagesEnv = process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - if (!messagesEnv) { - return null; - } - - try { - // Parse JSON with camelCase keys from Go struct (using json struct tags) - return JSON.parse(messagesEnv); - } catch (error) { - core.warning(`Failed to parse GH_AW_SAFE_OUTPUT_MESSAGES: ${error instanceof Error ? error.message : String(error)}`); - return null; - } - } - - /** - * Replace placeholders in a template string with values from context. - * Supports {key} syntax for placeholder replacement. - * @param {string} template - Template string with {key} placeholders - * @param {Record} context - Key-value pairs for replacement - * @returns {string} Template with placeholders replaced - */ - function renderTemplate(template, context) { - return template.replace(/\{(\w+)\}/g, (match, key) => { - const value = context[key]; - return value !== undefined && value !== null ? String(value) : match; - }); - } - - /** - * Convert context object keys to snake_case for template rendering - * @param {Record} obj - Object with camelCase keys - * @returns {Record} Object with snake_case keys - */ - function toSnakeCase(obj) { - /** @type {Record} */ - const result = {}; - for (const [key, value] of Object.entries(obj)) { - // Convert camelCase to snake_case - const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase(); - result[snakeKey] = value; - // Also keep original key for backwards compatibility - result[key] = value; - } - return result; - } - - module.exports = { - getMessages, - renderTemplate, - toSnakeCase, - }; - - EOF_6cdb27e0 - cat > /tmp/gh-aw/scripts/remove_duplicate_title.cjs << 'EOF_bb4a8126' - // @ts-check - /** - * Remove duplicate title from description - * @module remove_duplicate_title - */ - - /** - * Removes duplicate title from the beginning of description content. - * If the description starts with a header (# or ## or ### etc.) that matches - * the title, it will be removed along with any trailing newlines. - * - * @param {string} title - The title text to match and remove - * @param {string} description - The description content that may contain duplicate title - * @returns {string} The description with duplicate title removed - */ - function removeDuplicateTitleFromDescription(title, description) { - // Handle null/undefined/empty inputs - if (!title || typeof title !== "string") { - return description || ""; - } - if (!description || typeof description !== "string") { - return ""; - } - - const trimmedTitle = title.trim(); - const trimmedDescription = description.trim(); - - if (!trimmedTitle || !trimmedDescription) { - return trimmedDescription; - } - - // Match any header level (# to ######) followed by the title at the start - // This regex matches: - // - Start of string - // - One or more # characters - // - One or more spaces - // - The exact title (escaped for regex special chars) - // - Optional trailing spaces - // - Optional newlines after the header - const escapedTitle = trimmedTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const headerRegex = new RegExp(`^#{1,6}\\s+${escapedTitle}\\s*(?:\\r?\\n)*`, "i"); - - if (headerRegex.test(trimmedDescription)) { - return trimmedDescription.replace(headerRegex, "").trim(); - } - - return trimmedDescription; - } - - module.exports = { removeDuplicateTitleFromDescription }; - - EOF_bb4a8126 - cat > /tmp/gh-aw/scripts/repo_helpers.cjs << 'EOF_0e3d051f' - // @ts-check - /// - - /** - * Repository-related helper functions for safe-output scripts - * Provides common repository parsing, validation, and resolution logic - */ - - /** - * Parse the allowed repos from environment variable - * @returns {Set} Set of allowed repository slugs - */ - function parseAllowedRepos() { - const allowedReposEnv = process.env.GH_AW_ALLOWED_REPOS; - const set = new Set(); - if (allowedReposEnv) { - allowedReposEnv - .split(",") - .map(repo => repo.trim()) - .filter(repo => repo) - .forEach(repo => set.add(repo)); - } - return set; - } - - /** - * Get the default target repository - * @returns {string} Repository slug in "owner/repo" format - */ - function getDefaultTargetRepo() { - // First check if there's a target-repo override - const targetRepoSlug = process.env.GH_AW_TARGET_REPO_SLUG; - if (targetRepoSlug) { - return targetRepoSlug; - } - // Fall back to context repo - return `${context.repo.owner}/${context.repo.repo}`; - } - - /** - * Validate that a repo is allowed for operations - * @param {string} repo - Repository slug to validate - * @param {string} defaultRepo - Default target repository - * @param {Set} allowedRepos - Set of explicitly allowed repos - * @returns {{valid: boolean, error: string|null}} - */ - function validateRepo(repo, defaultRepo, allowedRepos) { - // Default repo is always allowed - if (repo === defaultRepo) { - return { valid: true, error: null }; - } - // Check if it's in the allowed repos list - if (allowedRepos.has(repo)) { - return { valid: true, error: null }; - } - return { - valid: false, - error: `Repository '${repo}' is not in the allowed-repos list. Allowed: ${defaultRepo}${allowedRepos.size > 0 ? ", " + Array.from(allowedRepos).join(", ") : ""}`, - }; - } - - /** - * Parse owner and repo from a repository slug - * @param {string} repoSlug - Repository slug in "owner/repo" format - * @returns {{owner: string, repo: string}|null} - */ - function parseRepoSlug(repoSlug) { - const parts = repoSlug.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - return null; - } - return { owner: parts[0], repo: parts[1] }; - } - - module.exports = { - parseAllowedRepos, - getDefaultTargetRepo, - validateRepo, - parseRepoSlug, - }; - - EOF_0e3d051f - cat > /tmp/gh-aw/scripts/temporary_id.cjs << 'EOF_795429aa' - // @ts-check - /// - - const crypto = require("crypto"); - - /** - * Regex pattern for matching temporary ID references in text - * Format: #aw_XXXXXXXXXXXX (aw_ prefix + 12 hex characters) - */ - const TEMPORARY_ID_PATTERN = /#(aw_[0-9a-f]{12})/gi; - - /** - * @typedef {Object} RepoIssuePair - * @property {string} repo - Repository slug in "owner/repo" format - * @property {number} number - Issue or discussion number - */ - - /** - * Generate a temporary ID with aw_ prefix for temporary issue IDs - * @returns {string} A temporary ID in format aw_XXXXXXXXXXXX (12 hex characters) - */ - function generateTemporaryId() { - return "aw_" + crypto.randomBytes(6).toString("hex"); - } - - /** - * Check if a value is a valid temporary ID (aw_ prefix + 12-character hex string) - * @param {any} value - The value to check - * @returns {boolean} True if the value is a valid temporary ID - */ - function isTemporaryId(value) { - if (typeof value === "string") { - return /^aw_[0-9a-f]{12}$/i.test(value); - } - return false; - } - - /** - * Normalize a temporary ID to lowercase for consistent map lookups - * @param {string} tempId - The temporary ID to normalize - * @returns {string} Lowercase temporary ID - */ - function normalizeTemporaryId(tempId) { - return String(tempId).toLowerCase(); - } - - /** - * Replace temporary ID references in text with actual issue numbers - * Format: #aw_XXXXXXXXXXXX -> #123 (same repo) or owner/repo#123 (cross-repo) - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @param {string} [currentRepo] - Current repository slug for same-repo references - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferences(text, tempIdMap, currentRepo) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const resolved = tempIdMap.get(normalizeTemporaryId(tempId)); - if (resolved !== undefined) { - // If we have a currentRepo and the issue is in the same repo, use short format - if (currentRepo && resolved.repo === currentRepo) { - return `#${resolved.number}`; - } - // Otherwise use full repo#number format for cross-repo references - return `${resolved.repo}#${resolved.number}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Replace temporary ID references in text with actual issue numbers (legacy format) - * This is a compatibility function that works with Map - * Format: #aw_XXXXXXXXXXXX -> #123 - * @param {string} text - The text to process - * @param {Map} tempIdMap - Map of temporary_id to issue number - * @returns {string} Text with temporary IDs replaced with issue numbers - */ - function replaceTemporaryIdReferencesLegacy(text, tempIdMap) { - return text.replace(TEMPORARY_ID_PATTERN, (match, tempId) => { - const issueNumber = tempIdMap.get(normalizeTemporaryId(tempId)); - if (issueNumber !== undefined) { - return `#${issueNumber}`; - } - // Return original if not found (it may be created later) - return match; - }); - } - - /** - * Load the temporary ID map from environment variable - * Supports both old format (temporary_id -> number) and new format (temporary_id -> {repo, number}) - * @returns {Map} Map of temporary_id to {repo, number} - */ - function loadTemporaryIdMap() { - const mapJson = process.env.GH_AW_TEMPORARY_ID_MAP; - if (!mapJson || mapJson === "{}") { - return new Map(); - } - try { - const mapObject = JSON.parse(mapJson); - /** @type {Map} */ - const result = new Map(); - - for (const [key, value] of Object.entries(mapObject)) { - const normalizedKey = normalizeTemporaryId(key); - if (typeof value === "number") { - // Legacy format: number only, use context repo - const contextRepo = `${context.repo.owner}/${context.repo.repo}`; - result.set(normalizedKey, { repo: contextRepo, number: value }); - } else if (typeof value === "object" && value !== null && "repo" in value && "number" in value) { - // New format: {repo, number} - result.set(normalizedKey, { repo: String(value.repo), number: Number(value.number) }); - } - } - return result; - } catch (error) { - if (typeof core !== "undefined") { - core.warning(`Failed to parse temporary ID map: ${error instanceof Error ? error.message : String(error)}`); - } - return new Map(); - } - } - - /** - * Resolve an issue number that may be a temporary ID or an actual issue number - * Returns structured result with the resolved number, repo, and metadata - * @param {any} value - The value to resolve (can be temporary ID, number, or string) - * @param {Map} temporaryIdMap - Map of temporary ID to {repo, number} - * @returns {{resolved: RepoIssuePair|null, wasTemporaryId: boolean, errorMessage: string|null}} - */ - function resolveIssueNumber(value, temporaryIdMap) { - if (value === undefined || value === null) { - return { resolved: null, wasTemporaryId: false, errorMessage: "Issue number is missing" }; - } - - // Check if it's a temporary ID - const valueStr = String(value); - if (isTemporaryId(valueStr)) { - const resolvedPair = temporaryIdMap.get(normalizeTemporaryId(valueStr)); - if (resolvedPair !== undefined) { - return { resolved: resolvedPair, wasTemporaryId: true, errorMessage: null }; - } - return { - resolved: null, - wasTemporaryId: true, - errorMessage: `Temporary ID '${valueStr}' not found in map. Ensure the issue was created before linking.`, - }; - } - - // It's a real issue number - use context repo as default - const issueNumber = typeof value === "number" ? value : parseInt(valueStr, 10); - if (isNaN(issueNumber) || issueNumber <= 0) { - return { resolved: null, wasTemporaryId: false, errorMessage: `Invalid issue number: ${value}` }; - } - - const contextRepo = typeof context !== "undefined" ? `${context.repo.owner}/${context.repo.repo}` : ""; - return { resolved: { repo: contextRepo, number: issueNumber }, wasTemporaryId: false, errorMessage: null }; - } - - /** - * Serialize the temporary ID map to JSON for output - * @param {Map} tempIdMap - Map of temporary_id to {repo, number} - * @returns {string} JSON string of the map - */ - function serializeTemporaryIdMap(tempIdMap) { - const obj = Object.fromEntries(tempIdMap); - return JSON.stringify(obj); - } - - module.exports = { - TEMPORARY_ID_PATTERN, - generateTemporaryId, - isTemporaryId, - normalizeTemporaryId, - replaceTemporaryIdReferences, - replaceTemporaryIdReferencesLegacy, - loadTemporaryIdMap, - resolveIssueNumber, - serializeTemporaryIdMap, - }; - - EOF_795429aa - - name: Create Discussion - id: create_discussion - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'create_discussion')) + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process All Safe Outputs + id: process_safe_outputs uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Weekly Issue Summary" - GH_AW_TRACKER_ID: "weekly-issue-summary" - GH_AW_ENGINE_ID: "copilot" - with: + GH_AW_DISCUSSION_CATEGORY: "Audits" + GH_AW_DISCUSSION_TITLE_PREFIX: "[Weekly Summary] " +GH_AW_CLOSE_OLDER_DISCUSSIONS=true with: github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - const { getTrackerID } = require('/tmp/gh-aw/scripts/get_tracker_id.cjs'); - const { closeOlderDiscussions } = require('/tmp/gh-aw/scripts/close_older_discussions.cjs'); - const { replaceTemporaryIdReferences, loadTemporaryIdMap } = require('/tmp/gh-aw/scripts/temporary_id.cjs'); - const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require('/tmp/gh-aw/scripts/repo_helpers.cjs'); - const { addExpirationComment } = require('/tmp/gh-aw/scripts/expiration_helpers.cjs'); - const { removeDuplicateTitleFromDescription } = require('/tmp/gh-aw/scripts/remove_duplicate_title.cjs'); - async function fetchRepoDiscussionInfo(owner, repo) { - const repositoryQuery = ` - query($owner: String!, $repo: String!) { - repository(owner: $owner, name: $repo) { - id - discussionCategories(first: 20) { - nodes { - id - name - slug - description - } - } - } - } - `; - const queryResult = await github.graphql(repositoryQuery, { - owner: owner, - repo: repo, - }); - if (!queryResult || !queryResult.repository) { - return null; - } - return { - repositoryId: queryResult.repository.id, - discussionCategories: queryResult.repository.discussionCategories.nodes || [], - }; - } - function resolveCategoryId(categoryConfig, itemCategory, categories) { - const categoryToMatch = itemCategory || categoryConfig; - if (categoryToMatch) { - const categoryById = categories.find(cat => cat.id === categoryToMatch); - if (categoryById) { - return { id: categoryById.id, matchType: "id", name: categoryById.name }; - } - const categoryByName = categories.find(cat => cat.name === categoryToMatch); - if (categoryByName) { - return { id: categoryByName.id, matchType: "name", name: categoryByName.name }; - } - const categoryBySlug = categories.find(cat => cat.slug === categoryToMatch); - if (categoryBySlug) { - return { id: categoryBySlug.id, matchType: "slug", name: categoryBySlug.name }; - } - } - if (categories.length > 0) { - return { - id: categories[0].id, - matchType: "fallback", - name: categories[0].name, - requestedCategory: categoryToMatch, - }; - } - return undefined; - } + const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); + const { loadAgentOutput } = require("./load_agent_output.cjs"); + const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); + const { handleCreateIssue } = require("./create_issue_handler.cjs"); async function main() { + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); core.setOutput("discussion_number", ""); core.setOutput("discussion_url", ""); - const temporaryIdMap = loadTemporaryIdMap(); - if (temporaryIdMap.size > 0) { - core.info(`Loaded temporary ID map with ${temporaryIdMap.size} entries`); - } + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; const result = loadAgentOutput(); if (!result.success) { + core.info("No agent output to process"); return; } - const createDiscussionItems = result.items.filter(item => item.type === "create_discussion"); - if (createDiscussionItems.length === 0) { - core.warning("No create-discussion items found in agent output"); - return; - } - core.info(`Found ${createDiscussionItems.length} create-discussion item(s)`); - const allowedRepos = parseAllowedRepos(); - const defaultTargetRepo = getDefaultTargetRepo(); - core.info(`Default target repo: ${defaultTargetRepo}`); - if (allowedRepos.size > 0) { - core.info(`Allowed repos: ${Array.from(allowedRepos).join(", ")}`); - } - if (process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true") { - let summaryContent = "## 🎭 Staged Mode: Create Discussions Preview\n\n"; - summaryContent += "The following discussions would be created if staged mode was disabled:\n\n"; - for (let i = 0; i < createDiscussionItems.length; i++) { - const item = createDiscussionItems[i]; - summaryContent += `### Discussion ${i + 1}\n`; - summaryContent += `**Title:** ${item.title || "No title provided"}\n\n`; - if (item.repo) { - summaryContent += `**Repository:** ${item.repo}\n\n`; - } - if (item.body) { - summaryContent += `**Body:**\n${item.body}\n\n`; - } - if (item.category) { - summaryContent += `**Category:** ${item.category}\n\n`; + core.info(`Loaded ${result.items.length} item(s) from agent output`); + const existingTempIdMap = loadTemporaryIdMap(); + const manager = new SafeOutputHandlerManager(); + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info(`Items by type: ${Array.from(typeCount.entries()).map(([type, count]) => `${type}(${count})`).join(", ")}`); + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + summaryContent += "---\n\n"; } - summaryContent += "---\n\n"; } await core.summary.addRaw(summaryContent).write(); - core.info("📝 Discussion creation preview written to step summary"); + core.info("📝 Safe outputs preview written to step summary"); return; } - const repoInfoCache = new Map(); - const closeOlderEnabled = process.env.GH_AW_CLOSE_OLDER_DISCUSSIONS === "true"; - const titlePrefix = process.env.GH_AW_DISCUSSION_TITLE_PREFIX || ""; - const configCategory = process.env.GH_AW_DISCUSSION_CATEGORY || ""; - const labelsEnvVar = process.env.GH_AW_DISCUSSION_LABELS || ""; - const labels = labelsEnvVar - ? labelsEnvVar - .split(",") - .map(l => l.trim()) - .filter(l => l.length > 0) - : []; - const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; - const runId = context.runId; - const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; - const createdDiscussions = []; - const closedDiscussionsSummary = []; - for (let i = 0; i < createDiscussionItems.length; i++) { - const createDiscussionItem = createDiscussionItems[i]; - const itemRepo = createDiscussionItem.repo ? String(createDiscussionItem.repo).trim() : defaultTargetRepo; - const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); - if (!repoValidation.valid) { - core.warning(`Skipping discussion: ${repoValidation.error}`); - continue; - } - const repoParts = parseRepoSlug(itemRepo); - if (!repoParts) { - core.warning(`Skipping discussion: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); - continue; - } - let repoInfo = repoInfoCache.get(itemRepo); - if (!repoInfo) { - try { - const fetchedInfo = await fetchRepoDiscussionInfo(repoParts.owner, repoParts.repo); - if (!fetchedInfo) { - core.warning(`Skipping discussion: Failed to fetch repository information for '${itemRepo}'`); - continue; - } - repoInfo = fetchedInfo; - repoInfoCache.set(itemRepo, repoInfo); - core.info(`Fetched discussion categories for ${itemRepo}: ${JSON.stringify(repoInfo.discussionCategories.map(cat => ({ name: cat.name, id: cat.id })))}`); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes("Not Found") || errorMessage.includes("not found") || errorMessage.includes("Could not resolve to a Repository")) { - core.warning(`Skipping discussion: Discussions are not enabled for repository '${itemRepo}'`); - continue; + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + } + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; } - core.error(`Failed to get discussion categories for ${itemRepo}: ${errorMessage}`); - throw error; } } - const categoryInfo = resolveCategoryId(configCategory, createDiscussionItem.category, repoInfo.discussionCategories); - if (!categoryInfo) { - core.warning(`Skipping discussion in ${itemRepo}: No discussion category available`); - continue; - } - if (categoryInfo.matchType === "name") { - core.info(`Using category by name: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "slug") { - core.info(`Using category by slug: ${categoryInfo.name} (${categoryInfo.id})`); - } else if (categoryInfo.matchType === "fallback") { - if (categoryInfo.requestedCategory) { - const availableCategoryNames = repoInfo.discussionCategories.map(cat => cat.name).join(", "); - core.warning(`Category "${categoryInfo.requestedCategory}" not found by ID, name, or slug. Available categories: ${availableCategoryNames}`); - core.info(`Falling back to default category: ${categoryInfo.name} (${categoryInfo.id})`); - } else { - core.info(`Using default first category: ${categoryInfo.name} (${categoryInfo.id})`); - } - } - const categoryId = categoryInfo.id; - core.info(`Processing create-discussion item ${i + 1}/${createDiscussionItems.length}: title=${createDiscussionItem.title}, bodyLength=${createDiscussionItem.body?.length || 0}, repo=${itemRepo}`); - let title = createDiscussionItem.title ? replaceTemporaryIdReferences(createDiscussionItem.title.trim(), temporaryIdMap, itemRepo) : ""; - const bodyText = createDiscussionItem.body || ""; - let processedBody = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo); - processedBody = removeDuplicateTitleFromDescription(title, processedBody); - let bodyLines = processedBody.split("\n"); - if (!title) { - title = replaceTemporaryIdReferences(bodyText, temporaryIdMap, itemRepo) || "Agent Output"; - } - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - const trackerIDComment = getTrackerID("markdown"); - if (trackerIDComment) { - bodyLines.push(trackerIDComment); - } - addExpirationComment(bodyLines, "GH_AW_DISCUSSION_EXPIRES", "Discussion"); - bodyLines.push(``, ``, `> AI generated by [${workflowName}](${runUrl})`, ""); - const body = bodyLines.join("\n").trim(); - core.info(`Creating discussion in ${itemRepo} with title: ${title}`); - core.info(`Category ID: ${categoryId}`); - core.info(`Body length: ${body.length}`); - try { - const createDiscussionMutation = ` - mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repositoryId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - id - number - title - url - } - } - } - `; - const mutationResult = await github.graphql(createDiscussionMutation, { - repositoryId: repoInfo.repositoryId, - categoryId: categoryId, - title: title, - body: body, - }); - const discussion = mutationResult.createDiscussion.discussion; - if (!discussion) { - core.error(`Failed to create discussion in ${itemRepo}: No discussion data returned`); - continue; - } - core.info(`Created discussion ${itemRepo}#${discussion.number}: ${discussion.url}`); - createdDiscussions.push({ ...discussion, _repo: itemRepo }); - if (i === createDiscussionItems.length - 1) { - core.setOutput("discussion_number", discussion.number); - core.setOutput("discussion_url", discussion.url); - } - const hasMatchingCriteria = titlePrefix || labels.length > 0; - if (closeOlderEnabled && hasMatchingCriteria) { - core.info("close-older-discussions is enabled, searching for older discussions to close..."); - try { - const closedDiscussions = await closeOlderDiscussions(github, repoParts.owner, repoParts.repo, titlePrefix, labels, categoryId, { number: discussion.number, url: discussion.url }, workflowName, runUrl); - if (closedDiscussions.length > 0) { - closedDiscussionsSummary.push(...closedDiscussions); - core.info(`Closed ${closedDiscussions.length} older discussion(s) as outdated`); - } - } catch (closeError) { - core.warning(`Failed to close older discussions: ${closeError instanceof Error ? closeError.message : String(closeError)}`); - } - } else if (closeOlderEnabled && !hasMatchingCriteria) { - core.warning("close-older-discussions is enabled but no title-prefix or labels are set - skipping close older discussions"); - } - } catch (error) { - core.error(`✗ Failed to create discussion "${title}" in ${itemRepo}: ${error instanceof Error ? error.message : String(error)}`); - throw error; + } + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); } } - if (createdDiscussions.length > 0) { - let summaryContent = "\n\n## GitHub Discussions\n"; - for (const discussion of createdDiscussions) { - const repoLabel = discussion._repo !== defaultTargetRepo ? ` (${discussion._repo})` : ""; - summaryContent += `- Discussion #${discussion.number}${repoLabel}: [${discussion.title}](${discussion.url})\n`; + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } } - if (closedDiscussionsSummary.length > 0) { - summaryContent += "\n### Closed Older Discussions\n"; - for (const closed of closedDiscussionsSummary) { - summaryContent += `- Discussion #${closed.number}: [View](${closed.url}) (marked as outdated)\n`; + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; } } await core.summary.addRaw(summaryContent).write(); } - core.info(`Successfully created ${createdDiscussions.length} discussion(s)`); + core.info(`Completed processing ${result.items.length} safe output item(s)`); } - (async () => { await main(); })(); - - name: Upload Assets - id: upload_assets - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'upload_asset')) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Weekly Issue Summary" - GH_AW_TRACKER_ID: "weekly-issue-summary" - GH_AW_ENGINE_ID: "copilot" - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - globalThis.github = github; - globalThis.context = context; - globalThis.core = core; - globalThis.exec = exec; - globalThis.io = io; - const fs = require("fs"); - const path = require("path"); - const crypto = require("crypto"); - const { loadAgentOutput } = require('/tmp/gh-aw/scripts/load_agent_output.cjs'); - function normalizeBranchName(branchName) { - if (!branchName || typeof branchName !== "string" || branchName.trim() === "") { - return branchName; - } - let normalized = branchName.replace(/[^a-zA-Z0-9\-_/.]+/g, "-"); - normalized = normalized.replace(/-+/g, "-"); - normalized = normalized.replace(/^-+|-+$/g, ""); - if (normalized.length > 128) { - normalized = normalized.substring(0, 128); - } - normalized = normalized.replace(/-+$/, ""); - normalized = normalized.toLowerCase(); - return normalized; - } - async function main() { - const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - const branchName = process.env.GH_AW_ASSETS_BRANCH; - if (!branchName || typeof branchName !== "string") { - core.setFailed("GH_AW_ASSETS_BRANCH environment variable is required but not set"); - return; - } - const normalizedBranchName = normalizeBranchName(branchName); - core.info(`Using assets branch: ${normalizedBranchName}`); - const result = loadAgentOutput(); - if (!result.success) { - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - const uploadItems = result.items.filter( item => item.type === "upload_assets"); - const uploadAssetItems = result.items.filter( item => item.type === "upload_asset"); - const allUploadItems = [...uploadItems, ...uploadAssetItems]; - if (allUploadItems.length === 0) { - core.info("No upload-asset items found in agent output"); - core.setOutput("upload_count", "0"); - core.setOutput("branch_name", normalizedBranchName); - return; - } - core.info(`Found ${allUploadItems.length} upload-asset item(s)`); - let uploadCount = 0; - let hasChanges = false; + (async () => { try { - try { - await exec.exec(`git rev-parse --verify origin/${normalizedBranchName}`); - await exec.exec(`git checkout -B ${normalizedBranchName} origin/${normalizedBranchName}`); - core.info(`Checked out existing branch from origin: ${normalizedBranchName}`); - } catch (originError) { - if (!normalizedBranchName.startsWith("assets/")) { - core.setFailed( - `Branch '${normalizedBranchName}' does not start with the required 'assets/' prefix. ` + - `Orphaned branches can only be automatically created under the 'assets/' prefix. ` + - `Please create the branch manually first, or use a branch name starting with 'assets/'.` - ); - return; - } - core.info(`Creating new orphaned branch: ${normalizedBranchName}`); - await exec.exec(`git checkout --orphan ${normalizedBranchName}`); - await exec.exec(`git rm -rf .`); - await exec.exec(`git clean -fdx`); - } - for (const asset of uploadAssetItems) { - try { - const { fileName, sha, size, targetFileName } = asset; - if (!fileName || !sha || !targetFileName) { - core.error(`Invalid asset entry missing required fields: ${JSON.stringify(asset)}`); - continue; - } - const assetSourcePath = path.join("/tmp/gh-aw/safeoutputs/assets", fileName); - if (!fs.existsSync(assetSourcePath)) { - core.warning(`Asset file not found: ${assetSourcePath}`); - continue; - } - const fileContent = fs.readFileSync(assetSourcePath); - const computedSha = crypto.createHash("sha256").update(fileContent).digest("hex"); - if (computedSha !== sha) { - core.warning(`SHA mismatch for ${fileName}: expected ${sha}, got ${computedSha}`); - continue; - } - if (fs.existsSync(targetFileName)) { - core.info(`Asset ${targetFileName} already exists, skipping`); - continue; - } - fs.copyFileSync(assetSourcePath, targetFileName); - await exec.exec(`git add "${targetFileName}"`); - uploadCount++; - hasChanges = true; - core.info(`Added asset: ${targetFileName} (${size} bytes)`); - } catch (error) { - core.warning(`Failed to process asset ${asset.fileName}: ${error instanceof Error ? error.message : String(error)}`); - } - } - if (hasChanges) { - const commitMessage = `[skip-ci] Add ${uploadCount} asset(s)`; - await exec.exec(`git`, [`commit`, `-m`, commitMessage]); - if (isStaged) { - core.summary.addRaw("## Staged Asset Publication"); - } else { - await exec.exec(`git push origin ${normalizedBranchName}`); - core.summary.addRaw("## Assets").addRaw(`Successfully uploaded **${uploadCount}** assets to branch \`${normalizedBranchName}\``).addRaw(""); - core.info(`Successfully uploaded ${uploadCount} assets to branch ${normalizedBranchName}`); - } - for (const asset of uploadAssetItems) { - if (asset.fileName && asset.sha && asset.size && asset.url) { - core.summary.addRaw(`- [\`${asset.fileName}\`](${asset.url}) → \`${asset.targetFileName}\` (${asset.size} bytes)`); - } - } - core.summary.write(); - } else { - core.info("No new assets to upload"); - } + await main(); } catch (error) { - core.setFailed(`Failed to upload assets: ${error instanceof Error ? error.message : String(error)}`); - return; + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); } - core.setOutput("upload_count", uploadCount.toString()); - core.setOutput("branch_name", normalizedBranchName); - } - (async () => { await main(); })(); + })(); update_cache_memory: needs: diff --git a/pkg/workflow/compiler_safe_output_jobs.go b/pkg/workflow/compiler_safe_output_jobs.go index 832681fc03..98a9bb04f1 100644 --- a/pkg/workflow/compiler_safe_output_jobs.go +++ b/pkg/workflow/compiler_safe_output_jobs.go @@ -39,17 +39,17 @@ func (c *Compiler) buildSafeOutputsJobs(data *WorkflowData, jobName, markdownPat // Track safe output job names to establish dependencies for conclusion job var safeOutputJobNames []string - // Build consolidated safe outputs job containing all safe output operations as steps - consolidatedJob, consolidatedStepNames, err := c.buildConsolidatedSafeOutputsJob(data, jobName, markdownPath) + // Build unified safe outputs job with single processor step + unifiedJob, unifiedStepNames, err := c.buildUnifiedSafeOutputsJob(data, jobName, markdownPath) if err != nil { - return fmt.Errorf("failed to build consolidated safe outputs job: %w", err) + return fmt.Errorf("failed to build unified safe outputs job: %w", err) } - if consolidatedJob != nil { - if err := c.jobManager.AddJob(consolidatedJob); err != nil { - return fmt.Errorf("failed to add consolidated safe outputs job: %w", err) + if unifiedJob != nil { + if err := c.jobManager.AddJob(unifiedJob); err != nil { + return fmt.Errorf("failed to add unified safe outputs job: %w", err) } - safeOutputJobNames = append(safeOutputJobNames, consolidatedJob.Name) - compilerSafeOutputJobsLog.Printf("Added consolidated safe outputs job with %d steps: %v", len(consolidatedStepNames), consolidatedStepNames) + safeOutputJobNames = append(safeOutputJobNames, unifiedJob.Name) + compilerSafeOutputJobsLog.Printf("Added unified safe outputs job with single processor step: %v", unifiedStepNames) } // Build safe-jobs if configured diff --git a/pkg/workflow/compiler_safe_outputs_unified.go b/pkg/workflow/compiler_safe_outputs_unified.go new file mode 100644 index 0000000000..a3f753be3e --- /dev/null +++ b/pkg/workflow/compiler_safe_outputs_unified.go @@ -0,0 +1,312 @@ +package workflow + +import ( + "fmt" + + "github.com/githubnext/gh-aw/pkg/constants" + "github.com/githubnext/gh-aw/pkg/logger" +) + +var unifiedSafeOutputsLog = logger.New("workflow:unified_safe_outputs") + +// buildUnifiedSafeOutputsJob builds a single job with a single step that processes all safe output types. +// This is the refactored version that uses safe_outputs_processor_main.cjs with the handler manager. +// +// The unified processor: +// 1. Loads agent output once +// 2. Dispatches each message to the appropriate registered handler +// 3. Collects temporary IDs and makes them available to subsequent handlers +// 4. Sets all required outputs +func (c *Compiler) buildUnifiedSafeOutputsJob(data *WorkflowData, mainJobName, markdownPath string) (*Job, []string, error) { + if data.SafeOutputs == nil { + unifiedSafeOutputsLog.Print("No safe outputs configured, skipping unified job") + return nil, nil, nil + } + + unifiedSafeOutputsLog.Print("Building unified safe outputs job with single processor step") + + var steps []string + var outputs = make(map[string]string) + var permissions = NewPermissions() + + // Track whether threat detection job is enabled for step conditions + threatDetectionEnabled := data.SafeOutputs.ThreatDetection != nil + + // Collect all permissions needed by any enabled safe output type + if data.SafeOutputs.CreateIssues != nil { + permissions.Merge(NewPermissionsContentsReadIssuesWrite()) + } + if data.SafeOutputs.CreateDiscussions != nil { + permissions.Merge(NewPermissionsContentsReadDiscussionsWrite()) + } + if data.SafeOutputs.CreatePullRequests != nil { + permissions.Merge(NewPermissionsContentsWriteIssuesWritePRWrite()) + } + if data.SafeOutputs.AddComments != nil { + permissions.Merge(NewPermissionsContentsReadIssuesWritePRWriteDiscussionsWrite()) + } + if data.SafeOutputs.CloseDiscussions != nil { + permissions.Merge(NewPermissionsContentsReadDiscussionsWrite()) + } + if data.SafeOutputs.CloseIssues != nil { + permissions.Merge(NewPermissionsContentsReadIssuesWrite()) + } + if data.SafeOutputs.ClosePullRequests != nil { + permissions.Merge(NewPermissionsContentsReadPRWrite()) + } + if data.SafeOutputs.CreatePullRequestReviewComments != nil { + permissions.Merge(NewPermissionsContentsReadPRWrite()) + } + if data.SafeOutputs.CreateCodeScanningAlerts != nil { + permissions.Merge(NewPermissionsContentsReadSecurityEventsWrite()) + } + if data.SafeOutputs.AddLabels != nil { + permissions.Merge(NewPermissionsContentsReadIssuesWrite()) + } + if data.SafeOutputs.AddReviewer != nil { + permissions.Merge(NewPermissionsContentsReadPRWrite()) + } + if data.SafeOutputs.AssignMilestone != nil { + permissions.Merge(NewPermissionsContentsReadIssuesWrite()) + } + if data.SafeOutputs.AssignToAgent != nil { + permissions.Merge(NewPermissionsContentsReadIssuesWrite()) + } + if data.SafeOutputs.AssignToUser != nil { + permissions.Merge(NewPermissionsContentsReadIssuesWrite()) + } + if data.SafeOutputs.UpdateIssues != nil { + permissions.Merge(NewPermissionsContentsReadIssuesWrite()) + } + if data.SafeOutputs.UpdatePullRequests != nil { + permissions.Merge(NewPermissionsContentsReadPRWrite()) + } + if data.SafeOutputs.PushToPullRequestBranch != nil { + permissions.Merge(NewPermissionsContentsWriteIssuesWritePRWrite()) + } + if data.SafeOutputs.UploadAssets != nil { + permissions.Merge(NewPermissionsContentsWrite()) + } + if data.SafeOutputs.UpdateRelease != nil { + permissions.Merge(NewPermissionsContentsWrite()) + } + if data.SafeOutputs.LinkSubIssue != nil { + permissions.Merge(NewPermissionsContentsReadIssuesWrite()) + } + if data.SafeOutputs.HideComment != nil { + permissions.Merge(NewPermissionsContentsReadIssuesWrite()) + } + if data.SafeOutputs.CreateAgentTasks != nil { + permissions.Merge(NewPermissionsContentsReadIssuesWrite()) + } + if data.SafeOutputs.UpdateProjects != nil { + permissions.Merge(NewPermissionsContentsReadProjectsWrite()) + } + + // Add GitHub App token minting step if app is configured + if data.SafeOutputs.App != nil { + unifiedSafeOutputsLog.Print("Adding GitHub App token minting step") + } + + // Add artifact download steps once at the beginning + steps = append(steps, buildAgentOutputDownloadSteps()...) + + // Collect all script files needed by the unified processor + // The processor needs: safe_outputs_processor_main, handler_manager, create_issue_handler, and all their dependencies + scriptNames := []string{ + "safe_outputs_processor_main", + "safe_output_handler_manager", + "create_issue_handler", + } + + sources := GetJavaScriptSources() + scriptFilesResult, err := CollectAllJobScriptFiles(scriptNames, sources) + if err != nil { + unifiedSafeOutputsLog.Printf("Failed to collect script files: %v, falling back to inline mode", err) + scriptFilesResult = nil + } else { + unifiedSafeOutputsLog.Printf("File mode: collected %d files, %d bytes total", + len(scriptFilesResult.Files), scriptFilesResult.TotalSize) + } + + // Add JavaScript files setup step if using file mode + if scriptFilesResult != nil && len(scriptFilesResult.Files) > 0 { + preparedFiles := PrepareFilesForFileMode(scriptFilesResult.Files) + setupSteps := GenerateWriteScriptsStep(preparedFiles) + steps = append(steps, setupSteps...) + unifiedSafeOutputsLog.Printf("Added setup_scripts step with %d files", len(preparedFiles)) + } + + // Add GitHub App token minting step after artifact download but before main processing + if data.SafeOutputs.App != nil { + appTokenSteps := c.buildGitHubAppTokenMintStep(data.SafeOutputs.App, permissions) + steps = append(steps, appTokenSteps...) + } + + // Build the unified processor step + // This single step replaces all the individual safe output steps + stepScript := sources["safe_outputs_processor_main.cjs"] + if stepScript == "" { + return nil, nil, fmt.Errorf("safe_outputs_processor_main.cjs script not found") + } + + // Collect all custom environment variables from all safe output configs + var customEnvVars []string + + // Add environment variables for each enabled safe output type + // These mirror what the individual steps would have had + if data.SafeOutputs.CreateIssues != nil { + customEnvVars = append(customEnvVars, c.buildCreateIssueEnvVars(data.SafeOutputs.CreateIssues)...) + } + if data.SafeOutputs.CreateDiscussions != nil { + customEnvVars = append(customEnvVars, c.buildCreateDiscussionEnvVars(data.SafeOutputs.CreateDiscussions)...) + } + if data.SafeOutputs.CreatePullRequests != nil { + customEnvVars = append(customEnvVars, c.buildCreatePullRequestEnvVars(data.SafeOutputs.CreatePullRequests)...) + } + // Add other env vars as needed... + + // Build the step using inline mode (for now) + processorStepConfig := GitHubScriptStepConfig{ + StepName: "Process All Safe Outputs", + StepID: "process_safe_outputs", + MainJobName: mainJobName, + CustomEnvVars: customEnvVars, + Script: stepScript, + // Token will be determined by buildGitHubScriptStep + } + + processorSteps := c.buildGitHubScriptStep(data, processorStepConfig) + steps = append(steps, processorSteps...) + + // Add all outputs from the unified processor + // The processor sets these outputs which match what individual steps would have set + outputs["issue_number"] = "${{ steps.process_safe_outputs.outputs.issue_number }}" + outputs["issue_url"] = "${{ steps.process_safe_outputs.outputs.issue_url }}" + outputs["temporary_id_map"] = "${{ steps.process_safe_outputs.outputs.temporary_id_map }}" + outputs["pull_request_number"] = "${{ steps.process_safe_outputs.outputs.pull_request_number }}" + outputs["pull_request_url"] = "${{ steps.process_safe_outputs.outputs.pull_request_url }}" + outputs["discussion_number"] = "${{ steps.process_safe_outputs.outputs.discussion_number }}" + outputs["discussion_url"] = "${{ steps.process_safe_outputs.outputs.discussion_url }}" + outputs["issues_to_assign_copilot"] = "${{ steps.process_safe_outputs.outputs.issues_to_assign_copilot }}" + outputs["comment_id"] = "${{ steps.process_safe_outputs.outputs.comment_id }}" + outputs["comment_url"] = "${{ steps.process_safe_outputs.outputs.comment_url }}" + + // Add GitHub App token invalidation step at the end if app is configured + if data.SafeOutputs.App != nil { + steps = append(steps, c.buildGitHubAppTokenInvalidationStep()...) + } + + // Build the job condition + agentNotSkipped := BuildAnd( + &NotNode{Child: BuildFunctionCall("cancelled")}, + BuildNotEquals( + BuildPropertyAccess(fmt.Sprintf("needs.%s.result", constants.AgentJobName)), + BuildStringLiteral("skipped"), + ), + ) + + jobCondition := agentNotSkipped + if threatDetectionEnabled { + jobCondition = BuildAnd(agentNotSkipped, buildDetectionSuccessCondition()) + } + + // Build dependencies + needs := []string{mainJobName} + if threatDetectionEnabled { + needs = append(needs, constants.DetectionJobName) + } + // Add activation job dependency for jobs that need it + if data.SafeOutputs.CreatePullRequests != nil || data.SafeOutputs.PushToPullRequestBranch != nil { + needs = append(needs, constants.ActivationJobName) + } + + job := &Job{ + Name: "safe_outputs", + If: jobCondition.Render(), + RunsOn: c.formatSafeOutputsRunsOn(data.SafeOutputs), + Permissions: permissions.RenderToYAML(), + TimeoutMinutes: 15, + Steps: steps, + Outputs: outputs, + Needs: needs, + } + + unifiedSafeOutputsLog.Printf("Built unified safe outputs job with single processor step") + + // Return step names for compatibility + stepNames := []string{"process_safe_outputs"} + return job, stepNames, nil +} + +// Helper functions to build environment variables for each safe output type +// These use the existing helpers from safe_outputs_env_helpers.go + +func (c *Compiler) buildCreateIssueEnvVars(cfg *CreateIssuesConfig) []string { + var envVars []string + + envVars = append(envVars, buildTitlePrefixEnvVar("GH_AW_ISSUE_TITLE_PREFIX", cfg.TitlePrefix)...) + envVars = append(envVars, buildLabelsEnvVar("GH_AW_ISSUE_LABELS", cfg.Labels)...) + envVars = append(envVars, buildLabelsEnvVar("GH_AW_ISSUE_ALLOWED_LABELS", cfg.AllowedLabels)...) + envVars = append(envVars, buildAllowedReposEnvVar("GH_AW_ALLOWED_REPOS", cfg.AllowedRepos)...) + + if cfg.Expires != 0 { + envVars = append(envVars, fmt.Sprintf("GH_AW_ISSUE_EXPIRES=%d", cfg.Expires)) + } + + // Handle assignees - check for "copilot" assignee + for _, assignee := range cfg.Assignees { + if assignee == "copilot" || assignee == "github-copilot" { + envVars = append(envVars, "GH_AW_ASSIGN_COPILOT=true") + break + } + } + + return envVars +} + +func (c *Compiler) buildCreateDiscussionEnvVars(cfg *CreateDiscussionsConfig) []string { + var envVars []string + + envVars = append(envVars, buildCategoryEnvVar("GH_AW_DISCUSSION_CATEGORY", cfg.Category)...) + envVars = append(envVars, buildTitlePrefixEnvVar("GH_AW_DISCUSSION_TITLE_PREFIX", cfg.TitlePrefix)...) + envVars = append(envVars, buildAllowedReposEnvVar("GH_AW_ALLOWED_REPOS", cfg.AllowedRepos)...) + + if cfg.Expires != 0 { + envVars = append(envVars, fmt.Sprintf("GH_AW_DISCUSSION_EXPIRES=%d", cfg.Expires)) + } + + if cfg.CloseOlderDiscussions { + envVars = append(envVars, "GH_AW_CLOSE_OLDER_DISCUSSIONS=true") + } + + return envVars +} + +func (c *Compiler) buildCreatePullRequestEnvVars(cfg *CreatePullRequestsConfig) []string { + var envVars []string + + envVars = append(envVars, buildLabelsEnvVar("GH_AW_PR_LABELS", cfg.Labels)...) + envVars = append(envVars, buildTitlePrefixEnvVar("GH_AW_PR_TITLE_PREFIX", cfg.TitlePrefix)...) + + if cfg.Draft != nil { + if *cfg.Draft { + envVars = append(envVars, "GH_AW_PR_DRAFT=true") + } else { + envVars = append(envVars, "GH_AW_PR_DRAFT=false") + } + } + + if cfg.Expires != 0 { + envVars = append(envVars, fmt.Sprintf("GH_AW_PR_EXPIRES=%d", cfg.Expires)) + } + + if cfg.AllowEmpty { + envVars = append(envVars, "GH_AW_PR_ALLOW_EMPTY=true") + } + if cfg.IfNoChanges != "" { + envVars = append(envVars, fmt.Sprintf("GH_AW_PR_IF_NO_CHANGES=%s", cfg.IfNoChanges)) + } + + return envVars +} diff --git a/pkg/workflow/js.go b/pkg/workflow/js.go index 6cd1f40d43..7e2bcc9c89 100644 --- a/pkg/workflow/js.go +++ b/pkg/workflow/js.go @@ -279,6 +279,15 @@ var safeOutputsToolsLoaderScript string //go:embed js/safe_outputs_bootstrap.cjs var safeOutputsBootstrapScript string +//go:embed js/safe_output_handler_manager.cjs +var safeOutputHandlerManagerScript string + +//go:embed js/create_issue_handler.cjs +var createIssueHandlerScript string + +//go:embed js/safe_outputs_processor_main.cjs +var safeOutputsProcessorMainScript string + //go:embed js/resolve_mentions_from_payload.cjs var resolveMentionsFromPayloadScript string @@ -353,6 +362,9 @@ func GetJavaScriptSources() map[string]string { "safe_outputs_tools_loader.cjs": safeOutputsToolsLoaderScript, "safe_outputs_tools.json": safeOutputsToolsJSON, "safe_outputs_bootstrap.cjs": safeOutputsBootstrapScript, + "safe_output_handler_manager.cjs": safeOutputHandlerManagerScript, + "create_issue_handler.cjs": createIssueHandlerScript, + "safe_outputs_processor_main.cjs": safeOutputsProcessorMainScript, "safe_outputs_mcp_server.cjs": safeOutputsMCPServerScriptSource, "resolve_mentions_from_payload.cjs": resolveMentionsFromPayloadScript, "sanitize_incoming_text.cjs": sanitizeIncomingTextScript, diff --git a/pkg/workflow/js/create_issue_handler.cjs b/pkg/workflow/js/create_issue_handler.cjs new file mode 100644 index 0000000000..d50d186d52 --- /dev/null +++ b/pkg/workflow/js/create_issue_handler.cjs @@ -0,0 +1,266 @@ +// @ts-check +/// + +const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); +const { generateStagedPreview } = require("./staged_preview.cjs"); +const { generateFooter } = require("./generate_footer.cjs"); +const { getTrackerID } = require("./get_tracker_id.cjs"); +const { generateTemporaryId, isTemporaryId, normalizeTemporaryId, replaceTemporaryIdReferences, serializeTemporaryIdMap } = require("./temporary_id.cjs"); +const { parseAllowedRepos, getDefaultTargetRepo, validateRepo, parseRepoSlug } = require("./repo_helpers.cjs"); +const { addExpirationComment } = require("./expiration_helpers.cjs"); +const { removeDuplicateTitleFromDescription } = require("./remove_duplicate_title.cjs"); + +/** + * Create issue handler - registers with the handler manager + * @param {any} item - The create_issue item from agent output + * @param {import("./safe_output_handler_manager.cjs").HandlerContext} context - Handler context + * @returns {Promise} + */ +async function handleCreateIssue(item, context) { + const { core, github, exec } = context; + const { context: ghContext } = context; + + // Map to track temporary_id -> {repo, number} relationships for this handler + /** @type {Map} */ + const localTemporaryIdMap = new Map(); + + try { + // Parse allowed repos and default target + const allowedRepos = parseAllowedRepos(); + const defaultTargetRepo = getDefaultTargetRepo(); + + // Determine target repository for this issue + const itemRepo = item.repo ? String(item.repo).trim() : defaultTargetRepo; + + // Validate the repository is allowed + const repoValidation = validateRepo(itemRepo, defaultTargetRepo, allowedRepos); + if (!repoValidation.valid) { + core.warning(`Skipping issue: ${repoValidation.error}`); + return { success: true }; // Not a failure, just skipped + } + + // Parse the repository slug + const repoParts = parseRepoSlug(itemRepo); + if (!repoParts) { + core.warning(`Skipping issue: Invalid repository format '${itemRepo}'. Expected 'owner/repo'.`); + return { success: true }; // Not a failure, just skipped + } + + // Get or generate the temporary ID for this issue + const temporaryId = item.temporary_id || generateTemporaryId(); + core.info(`Processing create-issue item: title=${item.title}, bodyLength=${item.body.length}, temporaryId=${temporaryId}, repo=${itemRepo}`); + + // Resolve parent: check if it's a temporary ID reference + let effectiveParentIssueNumber; + let effectiveParentRepo = itemRepo; // Default to same repo + + if (item.parent !== undefined) { + if (isTemporaryId(item.parent)) { + // It's a temporary ID, look it up in the map + const resolvedParent = context.temporaryIdMap.get(normalizeTemporaryId(item.parent)); + if (resolvedParent !== undefined) { + effectiveParentIssueNumber = resolvedParent.number; + effectiveParentRepo = resolvedParent.repo; + core.info(`Resolved parent temporary ID '${item.parent}' to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); + } else { + core.warning(`Parent temporary ID '${item.parent}' not found in map. Ensure parent issue is created before sub-issues.`); + effectiveParentIssueNumber = undefined; + } + } else { + // It's a real issue number + effectiveParentIssueNumber = parseInt(String(item.parent), 10); + if (isNaN(effectiveParentIssueNumber)) { + core.warning(`Invalid parent value: ${item.parent}`); + effectiveParentIssueNumber = undefined; + } + } + } else { + // Only use context parent if we're in the same repo as context + const contextRepo = `${ghContext.repo.owner}/${ghContext.repo.repo}`; + if (itemRepo === contextRepo) { + effectiveParentIssueNumber = ghContext.payload?.issue?.number; + } + } + + // Get labels + const labelsEnv = process.env.GH_AW_ISSUE_LABELS; + let envLabels = labelsEnv + ? labelsEnv + .split(",") + .map(label => label.trim()) + .filter(label => label) + : []; + + let labels = [...envLabels]; + if (item.labels && Array.isArray(item.labels)) { + labels = [...labels, ...item.labels]; + } + labels = labels + .filter(label => !!label) + .map(label => String(label).trim()) + .filter(label => label) + .map(label => sanitizeLabelContent(label)) + .filter(label => label) + .map(label => (label.length > 64 ? label.substring(0, 64) : label)) + .filter((label, index, arr) => arr.indexOf(label) === index); + + let title = item.title ? item.title.trim() : ""; + + // Replace temporary ID references in the body using already-created issues + let processedBody = replaceTemporaryIdReferences(item.body, context.temporaryIdMap, itemRepo); + + // Remove duplicate title from description if it starts with a header matching the title + processedBody = removeDuplicateTitleFromDescription(title, processedBody); + + let bodyLines = processedBody.split("\n"); + + if (!title) { + title = item.body || "Agent Output"; + } + const titlePrefix = process.env.GH_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + + if (effectiveParentIssueNumber) { + core.info("Detected issue context, parent issue " + effectiveParentRepo + "#" + effectiveParentIssueNumber); + // Use full repo reference if cross-repo, short reference if same repo + if (effectiveParentRepo === itemRepo) { + bodyLines.push(`Related to #${effectiveParentIssueNumber}`); + } else { + bodyLines.push(`Related to ${effectiveParentRepo}#${effectiveParentIssueNumber}`); + } + } + + const workflowName = process.env.GH_AW_WORKFLOW_NAME || "Workflow"; + const runId = ghContext.runId; + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const runUrl = ghContext.payload.repository ? `${ghContext.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${ghContext.repo.owner}/${ghContext.repo.repo}/actions/runs/${runId}`; + + // Add tracker-id comment if present + const trackerIDComment = getTrackerID("markdown"); + if (trackerIDComment) { + bodyLines.push(trackerIDComment); + } + + // Add expiration comment if expires is set + addExpirationComment(bodyLines, "GH_AW_ISSUE_EXPIRES", "Issue"); + + // Extract triggering context for footer generation + const triggeringIssueNumber = ghContext.payload?.issue?.number && !ghContext.payload?.issue?.pull_request ? ghContext.payload.issue.number : undefined; + const triggeringPRNumber = ghContext.payload?.pull_request?.number || (ghContext.payload?.issue?.pull_request ? ghContext.payload.issue.number : undefined); + const triggeringDiscussionNumber = ghContext.payload?.discussion?.number; + + const workflowSource = process.env.GH_AW_WORKFLOW_SOURCE || ""; + const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; + + bodyLines.push(``, ``, generateFooter(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber).trimEnd(), ""); + const body = bodyLines.join("\n").trim(); + + core.info(`Creating issue in ${itemRepo} with title: ${title}`); + core.info(`Labels: ${labels}`); + core.info(`Body length: ${body.length}`); + + const { data: issue } = await github.rest.issues.create({ + owner: repoParts.owner, + repo: repoParts.repo, + title: title, + body: body, + labels: labels, + }); + + core.info(`Created issue ${itemRepo}#${issue.number}: ${issue.html_url}`); + + // Store the mapping of temporary_id -> {repo, number} + localTemporaryIdMap.set(normalizeTemporaryId(temporaryId), { repo: itemRepo, number: issue.number }); + core.info(`Stored temporary ID mapping: ${temporaryId} -> ${itemRepo}#${issue.number}`); + + // Sub-issue linking only works within the same repository + if (effectiveParentIssueNumber && effectiveParentRepo === itemRepo) { + core.info(`Attempting to link issue #${issue.number} as sub-issue of #${effectiveParentIssueNumber}`); + try { + const getIssueNodeIdQuery = ` + query($owner: String!, $repo: String!, $issueNumber: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $issueNumber) { + id + } + } + } + `; + + // Get parent issue node ID + const parentResult = await github.graphql(getIssueNodeIdQuery, { + owner: repoParts.owner, + repo: repoParts.repo, + issueNumber: effectiveParentIssueNumber, + }); + const parentNodeId = parentResult.repository.issue.id; + + // Get child issue node ID + const childResult = await github.graphql(getIssueNodeIdQuery, { + owner: repoParts.owner, + repo: repoParts.repo, + issueNumber: issue.number, + }); + const childNodeId = childResult.repository.issue.id; + + // Link the child issue as a sub-issue of the parent + const addSubIssueMutation = ` + mutation($issueId: ID!, $subIssueId: ID!) { + addSubIssue(input: { + issueId: $issueId, + subIssueId: $subIssueId + }) { + subIssue { + id + number + } + } + } + `; + + await github.graphql(addSubIssueMutation, { + issueId: parentNodeId, + subIssueId: childNodeId, + }); + + core.info("✓ Successfully linked issue #" + issue.number + " as sub-issue of #" + effectiveParentIssueNumber); + } catch (error) { + core.info(`Warning: Could not link sub-issue to parent: ${error instanceof Error ? error.message : String(error)}`); + // Fallback: add a comment if sub-issue linking fails + try { + await github.rest.issues.createComment({ + owner: repoParts.owner, + repo: repoParts.repo, + issue_number: effectiveParentIssueNumber, + body: `Created related issue: #${issue.number}`, + }); + core.info("✓ Added comment to parent issue #" + effectiveParentIssueNumber + " (sub-issue linking not available)"); + } catch (commentError) { + core.info(`Warning: Could not add comment to parent issue: ${commentError instanceof Error ? commentError.message : String(commentError)}`); + } + } + } + + return { + success: true, + temporaryIds: localTemporaryIdMap, + data: { + issue_number: issue.number, + issue_url: issue.html_url, + repo: itemRepo, + }, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Issues has been disabled in this repository")) { + core.info(`⚠ Cannot create issue "${item.title}": Issues are disabled for this repository`); + return { success: true }; // Not a failure, just skipped + } + core.error(`✗ Failed to create issue "${item.title}": ${errorMessage}`); + return { success: false, error: errorMessage }; + } +} + +module.exports = { handleCreateIssue }; diff --git a/pkg/workflow/js/safe_output_handler_manager.cjs b/pkg/workflow/js/safe_output_handler_manager.cjs new file mode 100644 index 0000000000..3b711738ad --- /dev/null +++ b/pkg/workflow/js/safe_output_handler_manager.cjs @@ -0,0 +1,120 @@ +// @ts-check +/// + +/** + * Safe Output Handler Manager + * + * This module provides a registry and dispatcher for safe output handlers. + * Each handler can register itself for a specific message type, and the manager + * will dispatch messages to the appropriate handler, collecting temporary ID + * mappings along the way. + */ + +/** + * @typedef {Object} HandlerContext + * @property {typeof import("@actions/core")} core - GitHub Actions core + * @property {typeof import("@actions/github")} github - GitHub Actions toolkit + * @property {ReturnType} github - Octokit instance + * @property {typeof import("@actions/github").context} context - GitHub Actions context + * @property {typeof import("@actions/exec")} exec - GitHub Actions exec + * @property {Map} temporaryIdMap - Map of temporary IDs to issue/PR numbers + */ + +/** + * @typedef {Object} HandlerResult + * @property {boolean} success - Whether the handler succeeded + * @property {Map} [temporaryIds] - Temporary ID mappings created by this handler + * @property {string} [error] - Error message if handler failed + * @property {any} [data] - Additional data returned by the handler + */ + +/** + * @callback HandlerFunction + * @param {any} item - The safe output item to process + * @param {HandlerContext} context - Handler context with GitHub APIs and temporary ID map + * @returns {Promise} Result of handler execution + */ + +class SafeOutputHandlerManager { + constructor() { + /** @type {Map} */ + this.handlers = new Map(); + } + + /** + * Register a handler for a specific message type + * @param {string} type - Message type (e.g., "create_issue", "create_pull_request") + * @param {HandlerFunction} handler - Handler function + */ + registerHandler(type, handler) { + if (this.handlers.has(type)) { + throw new Error(`Handler already registered for type: ${type}`); + } + this.handlers.set(type, handler); + } + + /** + * Process all items from agent output + * @param {any[]} items - Array of safe output items + * @param {HandlerContext} context - Handler context + * @returns {Promise<{success: boolean, temporaryIdMap: Map, errors: string[]}>} + */ + async processAll(items, context) { + const errors = []; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + + if (!item.type) { + context.core.warning(`Item ${i} has no type, skipping`); + continue; + } + + const handler = this.handlers.get(item.type); + if (!handler) { + context.core.info(`No handler registered for type: ${item.type}, skipping`); + continue; + } + + try { + context.core.info(`Processing ${item.type} (${i + 1}/${items.length})`); + const result = await handler(item, context); + + if (!result.success) { + const error = `Handler for ${item.type} failed: ${result.error || "Unknown error"}`; + context.core.error(error); + errors.push(error); + } else { + // Merge temporary IDs from this handler into the global map + if (result.temporaryIds && result.temporaryIds.size > 0) { + for (const [tempId, value] of result.temporaryIds.entries()) { + context.temporaryIdMap.set(tempId, value); + context.core.info(`Registered temporary ID: ${tempId} -> ${value.repo}#${value.number}`); + } + } + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + context.core.error(`Exception in handler for ${item.type}: ${errorMessage}`); + errors.push(`Exception in ${item.type}: ${errorMessage}`); + } + } + + const success = errors.length === 0; + return { + success, + temporaryIdMap: context.temporaryIdMap, + errors, + }; + } + + /** + * Get list of registered handler types + * @returns {string[]} + */ + getRegisteredTypes() { + return Array.from(this.handlers.keys()); + } +} + +module.exports = { SafeOutputHandlerManager }; diff --git a/pkg/workflow/js/safe_output_handler_manager.test.cjs b/pkg/workflow/js/safe_output_handler_manager.test.cjs new file mode 100644 index 0000000000..f5ebab4b4b --- /dev/null +++ b/pkg/workflow/js/safe_output_handler_manager.test.cjs @@ -0,0 +1,186 @@ +// @ts-check + +import { describe, it, expect, beforeEach } from "vitest"; +import { SafeOutputHandlerManager } from "./safe_output_handler_manager.cjs"; + +describe("SafeOutputHandlerManager", () => { + /** @type {SafeOutputHandlerManager} */ + let manager; + /** @type {any} */ + let mockContext; + + beforeEach(() => { + manager = new SafeOutputHandlerManager(); + mockContext = { + core: { + info: () => {}, + warning: () => {}, + error: () => {}, + }, + github: {}, + context: {}, + exec: {}, + temporaryIdMap: new Map(), + }; + }); + + describe("registerHandler", () => { + it("should register a handler for a message type", () => { + const handler = async () => ({ success: true }); + manager.registerHandler("create_issue", handler); + + expect(manager.getRegisteredTypes()).toContain("create_issue"); + }); + + it("should throw error when registering duplicate type", () => { + const handler = async () => ({ success: true }); + manager.registerHandler("create_issue", handler); + + expect(() => { + manager.registerHandler("create_issue", handler); + }).toThrow("Handler already registered for type: create_issue"); + }); + + it("should allow multiple different types", () => { + manager.registerHandler("create_issue", async () => ({ success: true })); + manager.registerHandler("create_pull_request", async () => ({ success: true })); + + const types = manager.getRegisteredTypes(); + expect(types).toContain("create_issue"); + expect(types).toContain("create_pull_request"); + expect(types.length).toBe(2); + }); + }); + + describe("processAll", () => { + it("should process items with registered handlers", async () => { + let called = false; + manager.registerHandler("create_issue", async item => { + called = true; + expect(item.title).toBe("Test Issue"); + return { success: true }; + }); + + const items = [{ type: "create_issue", title: "Test Issue" }]; + const result = await manager.processAll(items, mockContext); + + expect(called).toBe(true); + expect(result.success).toBe(true); + expect(result.errors.length).toBe(0); + }); + + it("should skip items with no registered handler", async () => { + const items = [{ type: "unknown_type", data: "test" }]; + const result = await manager.processAll(items, mockContext); + + expect(result.success).toBe(true); + expect(result.errors.length).toBe(0); + }); + + it("should skip items with no type field", async () => { + const items = [{ data: "test" }]; + const result = await manager.processAll(items, mockContext); + + expect(result.success).toBe(true); + expect(result.errors.length).toBe(0); + }); + + it("should collect temporary IDs from handlers", async () => { + manager.registerHandler("create_issue", async () => { + const tempIds = new Map(); + tempIds.set("temp-1", { repo: "owner/repo", number: 42 }); + return { success: true, temporaryIds: tempIds }; + }); + + manager.registerHandler("create_pull_request", async () => { + const tempIds = new Map(); + tempIds.set("temp-2", { repo: "owner/repo", number: 100 }); + return { success: true, temporaryIds: tempIds }; + }); + + const items = [ + { type: "create_issue", title: "Issue" }, + { type: "create_pull_request", title: "PR" }, + ]; + const result = await manager.processAll(items, mockContext); + + expect(result.success).toBe(true); + expect(result.temporaryIdMap.size).toBe(2); + expect(result.temporaryIdMap.get("temp-1")).toEqual({ repo: "owner/repo", number: 42 }); + expect(result.temporaryIdMap.get("temp-2")).toEqual({ repo: "owner/repo", number: 100 }); + + // Also check that context.temporaryIdMap was updated + expect(mockContext.temporaryIdMap.size).toBe(2); + }); + + it("should pass temporary IDs from earlier handlers to later handlers", async () => { + manager.registerHandler("create_issue", async () => { + const tempIds = new Map(); + tempIds.set("temp-1", { repo: "owner/repo", number: 42 }); + return { success: true, temporaryIds: tempIds }; + }); + + manager.registerHandler("add_comment", async (item, context) => { + // This handler should be able to see temp IDs from create_issue + expect(context.temporaryIdMap.has("temp-1")).toBe(true); + expect(context.temporaryIdMap.get("temp-1")).toEqual({ repo: "owner/repo", number: 42 }); + return { success: true }; + }); + + const items = [ + { type: "create_issue", title: "Issue" }, + { type: "add_comment", body: "Comment", parent: "temp-1" }, + ]; + await manager.processAll(items, mockContext); + }); + + it("should collect errors from failed handlers", async () => { + manager.registerHandler("create_issue", async () => { + return { success: false, error: "Failed to create issue" }; + }); + + const items = [{ type: "create_issue", title: "Issue" }]; + const result = await manager.processAll(items, mockContext); + + expect(result.success).toBe(false); + expect(result.errors.length).toBe(1); + expect(result.errors[0]).toContain("Failed to create issue"); + }); + + it("should catch exceptions from handlers", async () => { + manager.registerHandler("create_issue", async () => { + throw new Error("Unexpected error"); + }); + + const items = [{ type: "create_issue", title: "Issue" }]; + const result = await manager.processAll(items, mockContext); + + expect(result.success).toBe(false); + expect(result.errors.length).toBe(1); + expect(result.errors[0]).toContain("Unexpected error"); + }); + + it("should process multiple items in sequence", async () => { + const callOrder = []; + + manager.registerHandler("create_issue", async item => { + callOrder.push(`issue:${item.title}`); + return { success: true }; + }); + + manager.registerHandler("create_pull_request", async item => { + callOrder.push(`pr:${item.title}`); + return { success: true }; + }); + + const items = [ + { type: "create_issue", title: "Issue 1" }, + { type: "create_pull_request", title: "PR 1" }, + { type: "create_issue", title: "Issue 2" }, + ]; + await manager.processAll(items, mockContext); + + expect(callOrder).toEqual(["issue:Issue 1", "pr:PR 1", "issue:Issue 2"]); + }); + }); +}); diff --git a/pkg/workflow/js/safe_outputs_processor_main.cjs b/pkg/workflow/js/safe_outputs_processor_main.cjs new file mode 100644 index 0000000000..d0e52dca4c --- /dev/null +++ b/pkg/workflow/js/safe_outputs_processor_main.cjs @@ -0,0 +1,240 @@ +// @ts-check +/// + +const { SafeOutputHandlerManager } = require("./safe_output_handler_manager.cjs"); +const { loadAgentOutput } = require("./load_agent_output.cjs"); +const { serializeTemporaryIdMap, loadTemporaryIdMap } = require("./temporary_id.cjs"); +const { handleCreateIssue } = require("./create_issue_handler.cjs"); + +/** + * Main processor for all safe output messages. + * This replaces the individual main() functions in each handler script. + * + * The processor: + * 1. Loads agent output + * 2. Registers all safe output handlers + * 3. Processes all messages in sequence + * 4. Collects temporary IDs and outputs + */ +async function main() { + // Initialize outputs to empty strings + core.setOutput("issue_number", ""); + core.setOutput("issue_url", ""); + core.setOutput("temporary_id_map", "{}"); + core.setOutput("pull_request_number", ""); + core.setOutput("pull_request_url", ""); + core.setOutput("discussion_number", ""); + core.setOutput("discussion_url", ""); + core.setOutput("issues_to_assign_copilot", ""); + core.setOutput("comment_id", ""); + core.setOutput("comment_url", ""); + + // Check if we're in staged mode + const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; + + // Load agent output + const result = loadAgentOutput(); + if (!result.success) { + core.info("No agent output to process"); + return; + } + + core.info(`Loaded ${result.items.length} item(s) from agent output`); + + // Load any existing temporary ID map (from previous steps or jobs) + const existingTempIdMap = loadTemporaryIdMap(); + + // Create handler manager + const manager = new SafeOutputHandlerManager(); + + // Register all handlers + core.info("Registering safe output handlers..."); + manager.registerHandler("create_issue", handleCreateIssue); + + // Note: Other handlers will be registered here as they are converted. + // For now, unregistered types will be skipped with an info message. + // TODO: Register remaining handlers: + // - create_pull_request + // - create_discussion + // - add_comment + // - close_issue, close_discussion, close_pull_request + // - update_issue, update_pull_request + // - add_labels, add_reviewer + // - assign_milestone, assign_to_agent, assign_to_user + // - create_pr_review_comment + // - create_code_scanning_alert + // - push_to_pull_request_branch + // - upload_assets + // - update_release + // - link_sub_issue + // - hide_comment + // - update_project + + const registeredTypes = manager.getRegisteredTypes(); + core.info(`Registered handlers for: ${registeredTypes.join(", ")}`); + + // Count items by type + const typeCount = new Map(); + for (const item of result.items) { + typeCount.set(item.type, (typeCount.get(item.type) || 0) + 1); + } + core.info( + `Items by type: ${Array.from(typeCount.entries()) + .map(([type, count]) => `${type}(${count})`) + .join(", ")}` + ); + + // If in staged mode, show preview for all items + if (isStaged) { + core.info("🎭 Staged mode enabled - generating preview"); + + // Group items by type for preview + const itemsByType = new Map(); + for (const item of result.items) { + if (!itemsByType.has(item.type)) { + itemsByType.set(item.type, []); + } + itemsByType.get(item.type).push(item); + } + + let summaryContent = "## 🎭 Staged Mode: Safe Outputs Preview\n\n"; + summaryContent += "The following safe outputs would be processed if staged mode was disabled:\n\n"; + + for (const [type, items] of itemsByType) { + summaryContent += `### ${type} (${items.length} item(s))\n\n`; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + summaryContent += `#### Item ${i + 1}\n`; + + // Show relevant fields based on type + if (item.title) { + summaryContent += `**Title:** ${item.title}\n\n`; + } + if (item.temporary_id) { + summaryContent += `**Temporary ID:** ${item.temporary_id}\n\n`; + } + if (item.repo) { + summaryContent += `**Repository:** ${item.repo}\n\n`; + } + if (item.body) { + const bodyPreview = item.body.length > 500 ? item.body.substring(0, 500) + "..." : item.body; + summaryContent += `**Body:**\n${bodyPreview}\n\n`; + } + if (item.labels && Array.isArray(item.labels) && item.labels.length > 0) { + summaryContent += `**Labels:** ${item.labels.join(", ")}\n\n`; + } + + summaryContent += "---\n\n"; + } + } + + await core.summary.addRaw(summaryContent).write(); + core.info("📝 Safe outputs preview written to step summary"); + return; + } + + // Create handler context with existing temporary IDs + const handlerContext = { + core, + github, + context, + exec, + temporaryIdMap: existingTempIdMap, + }; + + if (existingTempIdMap.size > 0) { + core.info(`Loaded ${existingTempIdMap.size} existing temporary ID mapping(s)`); + } + + // Process all items + core.info("Processing all safe output items..."); + const processResult = await manager.processAll(result.items, handlerContext); + + // Report results + if (processResult.success) { + core.info(`✓ Successfully processed all items`); + } else { + core.warning(`⚠ Processing completed with ${processResult.errors.length} error(s)`); + for (const error of processResult.errors) { + core.error(` - ${error}`); + } + // Don't fail the workflow for handler errors - they may be non-critical + } + + // Output temporary ID map + const tempIdMapOutput = serializeTemporaryIdMap(processResult.temporaryIdMap); + core.setOutput("temporary_id_map", tempIdMapOutput); + if (processResult.temporaryIdMap.size > 0) { + core.info(`Temporary ID map: ${tempIdMapOutput}`); + } + + // Set outputs from last item of each type + // This maintains compatibility with existing workflows that expect these outputs + const issueItems = result.items.filter(item => item.type === "create_issue"); + if (issueItems.length > 0) { + // Find the last issue that was successfully created + for (let i = issueItems.length - 1; i >= 0; i--) { + const tempId = issueItems[i].temporary_id; + if (tempId) { + const normalized = require("./temporary_id.cjs").normalizeTemporaryId(tempId); + const resolved = processResult.temporaryIdMap.get(normalized); + if (resolved) { + core.setOutput("issue_number", resolved.number); + // Reconstruct URL + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const issueUrl = `${githubServer}/${resolved.repo}/issues/${resolved.number}`; + core.setOutput("issue_url", issueUrl); + break; + } + } + } + } + + // Check for copilot assignment + const assignCopilot = process.env.GH_AW_ASSIGN_COPILOT === "true"; + if (assignCopilot && processResult.temporaryIdMap.size > 0) { + const issuesToAssign = Array.from(processResult.temporaryIdMap.values()) + .filter(value => value.repo && value.number) // Filter valid entries + .map(value => `${value.repo}:${value.number}`) + .join(","); + if (issuesToAssign) { + core.setOutput("issues_to_assign_copilot", issuesToAssign); + core.info(`Issues to assign copilot: ${issuesToAssign}`); + } + } + + // Add summary + if (processResult.temporaryIdMap.size > 0) { + let summaryContent = "\n\n## Safe Outputs\n"; + + // Group by type + const issuesCreated = []; + for (const [tempId, value] of processResult.temporaryIdMap.entries()) { + if (value.repo && value.number) { + issuesCreated.push({ tempId, ...value }); + } + } + + if (issuesCreated.length > 0) { + summaryContent += "\n### Created Issues\n"; + for (const item of issuesCreated) { + const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; + const url = `${githubServer}/${item.repo}/issues/${item.number}`; + summaryContent += `- [${item.repo}#${item.number}](${url})\n`; + } + } + + await core.summary.addRaw(summaryContent).write(); + } + + core.info(`Completed processing ${result.items.length} safe output item(s)`); +} + +// Execute main function +(async () => { + try { + await main(); + } catch (error) { + core.setFailed(`Safe outputs processor failed: ${error instanceof Error ? error.message : String(error)}`); + } +})();