From 6d5d38386015424e08ce91a10c5b3a660f6e89e1 Mon Sep 17 00:00:00 2001 From: Don Syme Date: Mon, 1 Sep 2025 21:22:00 +0100 Subject: [PATCH] add plural support in safe-outputs --- .../test-claude-add-issue-comment.lock.yml | 356 +++++++++------- .../test-claude-add-issue-labels.lock.yml | 234 ++++++----- .github/workflows/test-claude-alias.lock.yml | 356 +++++++++------- .../test-claude-create-issue.lock.yml | 382 ++++++++++-------- .../test-claude-create-pull-request.lock.yml | 234 ++++++----- .github/workflows/test-claude-mcp.lock.yml | 382 ++++++++++-------- .../test-codex-add-issue-comment.lock.yml | 356 +++++++++------- .../test-codex-add-issue-labels.lock.yml | 234 ++++++----- .github/workflows/test-codex-alias.lock.yml | 356 +++++++++------- .../test-codex-create-issue.lock.yml | 382 ++++++++++-------- .../test-codex-create-pull-request.lock.yml | 234 ++++++----- .github/workflows/test-codex-mcp.lock.yml | 382 ++++++++++-------- .github/workflows/test-proxy.lock.yml | 356 +++++++++------- docs/safe-outputs.md | 66 +-- pkg/cli/commands.go | 10 +- pkg/parser/schemas/main_workflow_schema.json | 53 +++ pkg/workflow/compiler.go | 371 +++++++++++------ pkg/workflow/compiler_test.go | 12 +- pkg/workflow/js/collect_ndjson_output.cjs | 262 ++++++------ .../js/collect_ndjson_output.test.cjs | 33 +- pkg/workflow/js/create_comment.cjs | 138 ++++--- pkg/workflow/js/create_issue.cjs | 156 +++---- pkg/workflow/output_test.go | 54 +-- .../plural_safe_outputs_integration_test.go | 213 ++++++++++ pkg/workflow/plural_safe_outputs_test.go | 206 ++++++++++ 25 files changed, 3518 insertions(+), 2300 deletions(-) create mode 100644 pkg/workflow/plural_safe_outputs_integration_test.go create mode 100644 pkg/workflow/plural_safe_outputs_test.go diff --git a/.github/workflows/test-claude-add-issue-comment.lock.yml b/.github/workflows/test-claude-add-issue-comment.lock.yml index 8409d6242de..d67ca37d530 100644 --- a/.github/workflows/test-claude-add-issue-comment.lock.yml +++ b/.github/workflows/test-claude-add-issue-comment.lock.yml @@ -428,113 +428,138 @@ jobs: GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"add-issue-comment\":true}" with: script: | - /** - * Sanitizes content for safe output in GitHub Actions - * @param {string} content - The content to sanitize - * @returns {string} The sanitized content - */ - function sanitizeContent(content) { - if (!content || typeof content !== 'string') { - return ''; - } - // Read allowed domains from environment variable - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = [ - 'github.com', - 'github.io', - 'githubusercontent.com', - 'githubassets.com', - 'github.dev', - 'codespaces.new' - ]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - // Neutralize @mentions to prevent unintended notifications - sanitized = neutralizeMentions(sanitized); - // Remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); - // XML character escaping - sanitized = sanitized - .replace(/&/g, '&') // Must be first to avoid double-escaping - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - // URI filtering - replace non-https protocols with "(redacted)" - sanitized = sanitizeUrlProtocols(sanitized); - // Domain filtering for HTTPS URIs - sanitized = sanitizeUrlDomains(sanitized); - // Limit total length to prevent DoS (0.5MB max) - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; - } - // Limit number of lines to prevent log flooding (65k max) - const lines = sanitized.split('\n'); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; - } - // Remove ANSI escape sequences - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); - // Neutralize common bot trigger phrases - sanitized = neutralizeBotTriggers(sanitized); - // Trim excessive whitespace - return sanitized.trim(); + async function main() { + const fs = require("fs"); /** - * Remove unknown domains - * @param {string} s - The string to process - * @returns {string} The string with unknown domains redacted + * Sanitizes content for safe output in GitHub Actions + * @param {string} content - The content to sanitize + * @returns {string} The sanitized content */ - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { - // Extract the hostname part (before first slash, colon, or other delimiter) - const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); - // Check if this domain or any parent domain is in the allowlist - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + function sanitizeContent(content) { + if (!content || typeof content !== 'string') { + return ''; + } + // Read allowed domains from environment variable + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = [ + 'github.com', + 'github.io', + 'githubusercontent.com', + 'githubassets.com', + 'github.dev', + 'codespaces.new' + ]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + // Neutralize @mentions to prevent unintended notifications + sanitized = neutralizeMentions(sanitized); + // Remove control characters (except newlines and tabs) + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); + // XML character escaping + sanitized = sanitized + .replace(/&/g, '&') // Must be first to avoid double-escaping + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + // URI filtering - replace non-https protocols with "(redacted)" + sanitized = sanitizeUrlProtocols(sanitized); + // Domain filtering for HTTPS URIs + sanitized = sanitizeUrlDomains(sanitized); + // Limit total length to prevent DoS (0.5MB max) + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; + } + // Limit number of lines to prevent log flooding (65k max) + const lines = sanitized.split('\n'); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; + } + // Remove ANSI escape sequences + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); + // Neutralize common bot trigger phrases + sanitized = neutralizeBotTriggers(sanitized); + // Trim excessive whitespace + return sanitized.trim(); + /** + * Remove unknown domains + * @param {string} s - The string to process + * @returns {string} The string with unknown domains redacted + */ + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { + // Extract the hostname part (before first slash, colon, or other delimiter) + const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); + // Check if this domain or any parent domain is in the allowlist + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + }); + return isAllowed ? match : '(redacted)'; }); - return isAllowed ? match : '(redacted)'; - }); - } - /** - * Remove unknown protocols except https - * @param {string} s - The string to process - * @returns {string} The string with non-https protocols redacted - */ - function sanitizeUrlProtocols(s) { - // Match both protocol:// and protocol: patterns - return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - // Allow https (case insensitive), redact everything else - return protocol.toLowerCase() === 'https' ? match : '(redacted)'; - }); - } - /** - * Neutralizes @mentions by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized mentions - */ - function neutralizeMentions(s) { - // Replace @name or @org/team outside code with `@name` - return s.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}\``); + } + /** + * Remove unknown protocols except https + * @param {string} s - The string to process + * @returns {string} The string with non-https protocols redacted + */ + function sanitizeUrlProtocols(s) { + // Match both protocol:// and protocol: patterns + return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + // Allow https (case insensitive), redact everything else + return protocol.toLowerCase() === 'https' ? match : '(redacted)'; + }); + } + /** + * Neutralizes @mentions by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized mentions + */ + function neutralizeMentions(s) { + // Replace @name or @org/team outside code with `@name` + return s.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}\``); + } + /** + * Neutralizes bot trigger phrases by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized bot triggers + */ + function neutralizeBotTriggers(s) { + // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, + (match, action, ref) => `\`${action} #${ref}\``); + } } /** - * Neutralizes bot trigger phrases by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized bot triggers + * Gets the maximum allowed count for a given output type + * @param {string} itemType - The output item type + * @param {Object} config - The safe-outputs configuration + * @returns {number} The maximum allowed count */ - function neutralizeBotTriggers(s) { - // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, - (match, action, ref) => `\`${action} #${ref}\``); + function getMaxAllowedForType(itemType, config) { + // Check if max is explicitly specified in config + if (config && config[itemType] && typeof config[itemType] === 'object' && config[itemType].max) { + return config[itemType].max; + } + // Use default limits for plural-supported types + switch (itemType) { + case 'create-issue': + return 10; // Allow multiple issues + case 'add-issue-comment': + return 10; // Allow multiple comments + case 'create-pull-request': + return 1; // Only one pull request allowed + case 'add-issue-labels': + return 1; // Only one labels operation allowed + default: + return 1; // Default to single item for unknown types + } } - } - async function main() { - const fs = require("fs"); const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; if (!outputFile) { @@ -584,10 +609,11 @@ jobs: errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(', ')}`); continue; } - // Check for duplicates based on type (since current format allows only one of each) - const existingItem = parsedItems.find(existing => existing.type === itemType); - if (existingItem) { - errors.push(`Line ${i + 1}: Duplicate output type '${itemType}'. Only one item of each type is currently allowed.`); + // Check for too many items of the same type + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); continue; } // Basic validation based on type @@ -1089,13 +1115,13 @@ jobs: console.log('No valid items found in agent output'); return; } - // Find the add-issue-comment item - const commentItem = validatedOutput.items.find(/** @param {any} item */ item => item.type === 'add-issue-comment'); - if (!commentItem) { - console.log('No add-issue-comment item found in agent output'); + // Find all add-issue-comment items + const commentItems = validatedOutput.items.filter(/** @param {any} item */ item => item.type === 'add-issue-comment'); + if (commentItems.length === 0) { + console.log('No add-issue-comment items found in agent output'); return; } - console.log('Found add-issue-comment item:', { bodyLength: commentItem.body.length }); + console.log(`Found ${commentItems.length} add-issue-comment item(s)`); // Check if we're in an issue or pull request context 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'; @@ -1103,57 +1129,75 @@ jobs: console.log('Not running in issue or pull request context, skipping comment creation'); return; } - // Determine the issue/PR number and comment endpoint - let issueNumber; - let commentEndpoint; - if (isIssueContext) { - if (context.payload.issue) { - issueNumber = context.payload.issue.number; - commentEndpoint = 'issues'; - } else { - console.log('Issue context detected but no issue found in payload'); - return; + const createdComments = []; + // Process each comment item + for (let i = 0; i < commentItems.length; i++) { + const commentItem = commentItems[i]; + console.log(`Processing add-issue-comment item ${i + 1}/${commentItems.length}:`, { bodyLength: commentItem.body.length }); + // Determine the issue/PR number and comment endpoint for this comment + let issueNumber; + let commentEndpoint; + if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; + commentEndpoint = 'issues'; + } else { + console.log('Issue context detected but no issue found in payload'); + continue; + } + } else if (isPRContext) { + if (context.payload.pull_request) { + issueNumber = context.payload.pull_request.number; + commentEndpoint = 'issues'; // PR comments use the issues API endpoint + } else { + console.log('Pull request context detected but no pull request found in payload'); + continue; + } } - } else if (isPRContext) { - if (context.payload.pull_request) { - issueNumber = context.payload.pull_request.number; - commentEndpoint = 'issues'; // PR comments use the issues API endpoint - } else { - console.log('Pull request context detected but no pull request found in payload'); - return; + if (!issueNumber) { + console.log('Could not determine issue or pull request number'); + continue; + } + // Extract body from the JSON item + let body = commentItem.body.trim(); + // Add AI disclaimer with run id, run htmlurl + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + body += `\n\n> Generated by Agentic Workflow Run [${runId}](${runUrl})\n`; + console.log(`Creating comment on ${commentEndpoint} #${issueNumber}`); + console.log('Comment content length:', body.length); + try { + // Create the comment using GitHub API + const { data: comment } = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: body + }); + console.log('Created comment #' + comment.id + ': ' + comment.html_url); + createdComments.push(comment); + // Set output for the last created comment (for backward compatibility) + if (i === commentItems.length - 1) { + core.setOutput('comment_id', comment.id); + core.setOutput('comment_url', comment.html_url); + } + } catch (error) { + console.error(`✗ Failed to create comment:`, error instanceof Error ? error.message : String(error)); + throw error; } } - if (!issueNumber) { - console.log('Could not determine issue or pull request number'); - return; + // Write summary for all created comments + if (createdComments.length > 0) { + let summaryContent = '\n\n## GitHub Comments\n'; + for (const comment of createdComments) { + summaryContent += `- Comment #${comment.id}: [View Comment](${comment.html_url})\n`; + } + await core.summary.addRaw(summaryContent).write(); } - // Extract body from the JSON item - let body = commentItem.body.trim(); - // Add AI disclaimer with run id, run htmlurl - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - body += `\n\n> Generated by Agentic Workflow Run [${runId}](${runUrl})\n`; - console.log(`Creating comment on ${commentEndpoint} #${issueNumber}`); - console.log('Comment content length:', body.length); - // Create the comment using GitHub API - const { data: comment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body: body - }); - console.log('Created comment #' + comment.id + ': ' + comment.html_url); - // Set output for other jobs to use - core.setOutput('comment_id', comment.id); - core.setOutput('comment_url', comment.html_url); - // write comment id, url to the github_step_summary - await core.summary.addRaw(` - ## GitHub Comment - - Comment ID: ${comment.id} - - Comment URL: ${comment.html_url} - `).write(); + console.log(`Successfully created ${createdComments.length} comment(s)`); + return createdComments; } await main(); diff --git a/.github/workflows/test-claude-add-issue-labels.lock.yml b/.github/workflows/test-claude-add-issue-labels.lock.yml index 7b7476b4d28..05a3f89a5c4 100644 --- a/.github/workflows/test-claude-add-issue-labels.lock.yml +++ b/.github/workflows/test-claude-add-issue-labels.lock.yml @@ -428,113 +428,138 @@ jobs: GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"add-issue-labels\":true}" with: script: | - /** - * Sanitizes content for safe output in GitHub Actions - * @param {string} content - The content to sanitize - * @returns {string} The sanitized content - */ - function sanitizeContent(content) { - if (!content || typeof content !== 'string') { - return ''; - } - // Read allowed domains from environment variable - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = [ - 'github.com', - 'github.io', - 'githubusercontent.com', - 'githubassets.com', - 'github.dev', - 'codespaces.new' - ]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - // Neutralize @mentions to prevent unintended notifications - sanitized = neutralizeMentions(sanitized); - // Remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); - // XML character escaping - sanitized = sanitized - .replace(/&/g, '&') // Must be first to avoid double-escaping - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - // URI filtering - replace non-https protocols with "(redacted)" - sanitized = sanitizeUrlProtocols(sanitized); - // Domain filtering for HTTPS URIs - sanitized = sanitizeUrlDomains(sanitized); - // Limit total length to prevent DoS (0.5MB max) - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; - } - // Limit number of lines to prevent log flooding (65k max) - const lines = sanitized.split('\n'); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; - } - // Remove ANSI escape sequences - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); - // Neutralize common bot trigger phrases - sanitized = neutralizeBotTriggers(sanitized); - // Trim excessive whitespace - return sanitized.trim(); + async function main() { + const fs = require("fs"); /** - * Remove unknown domains - * @param {string} s - The string to process - * @returns {string} The string with unknown domains redacted + * Sanitizes content for safe output in GitHub Actions + * @param {string} content - The content to sanitize + * @returns {string} The sanitized content */ - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { - // Extract the hostname part (before first slash, colon, or other delimiter) - const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); - // Check if this domain or any parent domain is in the allowlist - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + function sanitizeContent(content) { + if (!content || typeof content !== 'string') { + return ''; + } + // Read allowed domains from environment variable + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = [ + 'github.com', + 'github.io', + 'githubusercontent.com', + 'githubassets.com', + 'github.dev', + 'codespaces.new' + ]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + // Neutralize @mentions to prevent unintended notifications + sanitized = neutralizeMentions(sanitized); + // Remove control characters (except newlines and tabs) + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); + // XML character escaping + sanitized = sanitized + .replace(/&/g, '&') // Must be first to avoid double-escaping + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + // URI filtering - replace non-https protocols with "(redacted)" + sanitized = sanitizeUrlProtocols(sanitized); + // Domain filtering for HTTPS URIs + sanitized = sanitizeUrlDomains(sanitized); + // Limit total length to prevent DoS (0.5MB max) + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; + } + // Limit number of lines to prevent log flooding (65k max) + const lines = sanitized.split('\n'); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; + } + // Remove ANSI escape sequences + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); + // Neutralize common bot trigger phrases + sanitized = neutralizeBotTriggers(sanitized); + // Trim excessive whitespace + return sanitized.trim(); + /** + * Remove unknown domains + * @param {string} s - The string to process + * @returns {string} The string with unknown domains redacted + */ + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { + // Extract the hostname part (before first slash, colon, or other delimiter) + const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); + // Check if this domain or any parent domain is in the allowlist + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + }); + return isAllowed ? match : '(redacted)'; }); - return isAllowed ? match : '(redacted)'; - }); - } - /** - * Remove unknown protocols except https - * @param {string} s - The string to process - * @returns {string} The string with non-https protocols redacted - */ - function sanitizeUrlProtocols(s) { - // Match both protocol:// and protocol: patterns - return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - // Allow https (case insensitive), redact everything else - return protocol.toLowerCase() === 'https' ? match : '(redacted)'; - }); - } - /** - * Neutralizes @mentions by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized mentions - */ - function neutralizeMentions(s) { - // Replace @name or @org/team outside code with `@name` - return s.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}\``); + } + /** + * Remove unknown protocols except https + * @param {string} s - The string to process + * @returns {string} The string with non-https protocols redacted + */ + function sanitizeUrlProtocols(s) { + // Match both protocol:// and protocol: patterns + return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + // Allow https (case insensitive), redact everything else + return protocol.toLowerCase() === 'https' ? match : '(redacted)'; + }); + } + /** + * Neutralizes @mentions by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized mentions + */ + function neutralizeMentions(s) { + // Replace @name or @org/team outside code with `@name` + return s.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}\``); + } + /** + * Neutralizes bot trigger phrases by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized bot triggers + */ + function neutralizeBotTriggers(s) { + // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, + (match, action, ref) => `\`${action} #${ref}\``); + } } /** - * Neutralizes bot trigger phrases by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized bot triggers + * Gets the maximum allowed count for a given output type + * @param {string} itemType - The output item type + * @param {Object} config - The safe-outputs configuration + * @returns {number} The maximum allowed count */ - function neutralizeBotTriggers(s) { - // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, - (match, action, ref) => `\`${action} #${ref}\``); + function getMaxAllowedForType(itemType, config) { + // Check if max is explicitly specified in config + if (config && config[itemType] && typeof config[itemType] === 'object' && config[itemType].max) { + return config[itemType].max; + } + // Use default limits for plural-supported types + switch (itemType) { + case 'create-issue': + return 10; // Allow multiple issues + case 'add-issue-comment': + return 10; // Allow multiple comments + case 'create-pull-request': + return 1; // Only one pull request allowed + case 'add-issue-labels': + return 1; // Only one labels operation allowed + default: + return 1; // Default to single item for unknown types + } } - } - async function main() { - const fs = require("fs"); const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; if (!outputFile) { @@ -584,10 +609,11 @@ jobs: errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(', ')}`); continue; } - // Check for duplicates based on type (since current format allows only one of each) - const existingItem = parsedItems.find(existing => existing.type === itemType); - if (existingItem) { - errors.push(`Line ${i + 1}: Duplicate output type '${itemType}'. Only one item of each type is currently allowed.`); + // Check for too many items of the same type + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); continue; } // Basic validation based on type diff --git a/.github/workflows/test-claude-alias.lock.yml b/.github/workflows/test-claude-alias.lock.yml index c4eafa46cfa..8534873fd48 100644 --- a/.github/workflows/test-claude-alias.lock.yml +++ b/.github/workflows/test-claude-alias.lock.yml @@ -666,113 +666,138 @@ jobs: GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"add-issue-comment\":true}" with: script: | - /** - * Sanitizes content for safe output in GitHub Actions - * @param {string} content - The content to sanitize - * @returns {string} The sanitized content - */ - function sanitizeContent(content) { - if (!content || typeof content !== 'string') { - return ''; - } - // Read allowed domains from environment variable - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = [ - 'github.com', - 'github.io', - 'githubusercontent.com', - 'githubassets.com', - 'github.dev', - 'codespaces.new' - ]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - // Neutralize @mentions to prevent unintended notifications - sanitized = neutralizeMentions(sanitized); - // Remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); - // XML character escaping - sanitized = sanitized - .replace(/&/g, '&') // Must be first to avoid double-escaping - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - // URI filtering - replace non-https protocols with "(redacted)" - sanitized = sanitizeUrlProtocols(sanitized); - // Domain filtering for HTTPS URIs - sanitized = sanitizeUrlDomains(sanitized); - // Limit total length to prevent DoS (0.5MB max) - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; - } - // Limit number of lines to prevent log flooding (65k max) - const lines = sanitized.split('\n'); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; - } - // Remove ANSI escape sequences - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); - // Neutralize common bot trigger phrases - sanitized = neutralizeBotTriggers(sanitized); - // Trim excessive whitespace - return sanitized.trim(); + async function main() { + const fs = require("fs"); /** - * Remove unknown domains - * @param {string} s - The string to process - * @returns {string} The string with unknown domains redacted + * Sanitizes content for safe output in GitHub Actions + * @param {string} content - The content to sanitize + * @returns {string} The sanitized content */ - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { - // Extract the hostname part (before first slash, colon, or other delimiter) - const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); - // Check if this domain or any parent domain is in the allowlist - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + function sanitizeContent(content) { + if (!content || typeof content !== 'string') { + return ''; + } + // Read allowed domains from environment variable + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = [ + 'github.com', + 'github.io', + 'githubusercontent.com', + 'githubassets.com', + 'github.dev', + 'codespaces.new' + ]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + // Neutralize @mentions to prevent unintended notifications + sanitized = neutralizeMentions(sanitized); + // Remove control characters (except newlines and tabs) + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); + // XML character escaping + sanitized = sanitized + .replace(/&/g, '&') // Must be first to avoid double-escaping + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + // URI filtering - replace non-https protocols with "(redacted)" + sanitized = sanitizeUrlProtocols(sanitized); + // Domain filtering for HTTPS URIs + sanitized = sanitizeUrlDomains(sanitized); + // Limit total length to prevent DoS (0.5MB max) + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; + } + // Limit number of lines to prevent log flooding (65k max) + const lines = sanitized.split('\n'); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; + } + // Remove ANSI escape sequences + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); + // Neutralize common bot trigger phrases + sanitized = neutralizeBotTriggers(sanitized); + // Trim excessive whitespace + return sanitized.trim(); + /** + * Remove unknown domains + * @param {string} s - The string to process + * @returns {string} The string with unknown domains redacted + */ + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { + // Extract the hostname part (before first slash, colon, or other delimiter) + const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); + // Check if this domain or any parent domain is in the allowlist + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + }); + return isAllowed ? match : '(redacted)'; }); - return isAllowed ? match : '(redacted)'; - }); - } - /** - * Remove unknown protocols except https - * @param {string} s - The string to process - * @returns {string} The string with non-https protocols redacted - */ - function sanitizeUrlProtocols(s) { - // Match both protocol:// and protocol: patterns - return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - // Allow https (case insensitive), redact everything else - return protocol.toLowerCase() === 'https' ? match : '(redacted)'; - }); - } - /** - * Neutralizes @mentions by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized mentions - */ - function neutralizeMentions(s) { - // Replace @name or @org/team outside code with `@name` - return s.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}\``); + } + /** + * Remove unknown protocols except https + * @param {string} s - The string to process + * @returns {string} The string with non-https protocols redacted + */ + function sanitizeUrlProtocols(s) { + // Match both protocol:// and protocol: patterns + return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + // Allow https (case insensitive), redact everything else + return protocol.toLowerCase() === 'https' ? match : '(redacted)'; + }); + } + /** + * Neutralizes @mentions by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized mentions + */ + function neutralizeMentions(s) { + // Replace @name or @org/team outside code with `@name` + return s.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}\``); + } + /** + * Neutralizes bot trigger phrases by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized bot triggers + */ + function neutralizeBotTriggers(s) { + // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, + (match, action, ref) => `\`${action} #${ref}\``); + } } /** - * Neutralizes bot trigger phrases by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized bot triggers + * Gets the maximum allowed count for a given output type + * @param {string} itemType - The output item type + * @param {Object} config - The safe-outputs configuration + * @returns {number} The maximum allowed count */ - function neutralizeBotTriggers(s) { - // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, - (match, action, ref) => `\`${action} #${ref}\``); + function getMaxAllowedForType(itemType, config) { + // Check if max is explicitly specified in config + if (config && config[itemType] && typeof config[itemType] === 'object' && config[itemType].max) { + return config[itemType].max; + } + // Use default limits for plural-supported types + switch (itemType) { + case 'create-issue': + return 10; // Allow multiple issues + case 'add-issue-comment': + return 10; // Allow multiple comments + case 'create-pull-request': + return 1; // Only one pull request allowed + case 'add-issue-labels': + return 1; // Only one labels operation allowed + default: + return 1; // Default to single item for unknown types + } } - } - async function main() { - const fs = require("fs"); const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; if (!outputFile) { @@ -822,10 +847,11 @@ jobs: errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(', ')}`); continue; } - // Check for duplicates based on type (since current format allows only one of each) - const existingItem = parsedItems.find(existing => existing.type === itemType); - if (existingItem) { - errors.push(`Line ${i + 1}: Duplicate output type '${itemType}'. Only one item of each type is currently allowed.`); + // Check for too many items of the same type + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); continue; } // Basic validation based on type @@ -1327,13 +1353,13 @@ jobs: console.log('No valid items found in agent output'); return; } - // Find the add-issue-comment item - const commentItem = validatedOutput.items.find(/** @param {any} item */ item => item.type === 'add-issue-comment'); - if (!commentItem) { - console.log('No add-issue-comment item found in agent output'); + // Find all add-issue-comment items + const commentItems = validatedOutput.items.filter(/** @param {any} item */ item => item.type === 'add-issue-comment'); + if (commentItems.length === 0) { + console.log('No add-issue-comment items found in agent output'); return; } - console.log('Found add-issue-comment item:', { bodyLength: commentItem.body.length }); + console.log(`Found ${commentItems.length} add-issue-comment item(s)`); // Check if we're in an issue or pull request context 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'; @@ -1341,57 +1367,75 @@ jobs: console.log('Not running in issue or pull request context, skipping comment creation'); return; } - // Determine the issue/PR number and comment endpoint - let issueNumber; - let commentEndpoint; - if (isIssueContext) { - if (context.payload.issue) { - issueNumber = context.payload.issue.number; - commentEndpoint = 'issues'; - } else { - console.log('Issue context detected but no issue found in payload'); - return; + const createdComments = []; + // Process each comment item + for (let i = 0; i < commentItems.length; i++) { + const commentItem = commentItems[i]; + console.log(`Processing add-issue-comment item ${i + 1}/${commentItems.length}:`, { bodyLength: commentItem.body.length }); + // Determine the issue/PR number and comment endpoint for this comment + let issueNumber; + let commentEndpoint; + if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; + commentEndpoint = 'issues'; + } else { + console.log('Issue context detected but no issue found in payload'); + continue; + } + } else if (isPRContext) { + if (context.payload.pull_request) { + issueNumber = context.payload.pull_request.number; + commentEndpoint = 'issues'; // PR comments use the issues API endpoint + } else { + console.log('Pull request context detected but no pull request found in payload'); + continue; + } } - } else if (isPRContext) { - if (context.payload.pull_request) { - issueNumber = context.payload.pull_request.number; - commentEndpoint = 'issues'; // PR comments use the issues API endpoint - } else { - console.log('Pull request context detected but no pull request found in payload'); - return; + if (!issueNumber) { + console.log('Could not determine issue or pull request number'); + continue; + } + // Extract body from the JSON item + let body = commentItem.body.trim(); + // Add AI disclaimer with run id, run htmlurl + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + body += `\n\n> Generated by Agentic Workflow Run [${runId}](${runUrl})\n`; + console.log(`Creating comment on ${commentEndpoint} #${issueNumber}`); + console.log('Comment content length:', body.length); + try { + // Create the comment using GitHub API + const { data: comment } = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: body + }); + console.log('Created comment #' + comment.id + ': ' + comment.html_url); + createdComments.push(comment); + // Set output for the last created comment (for backward compatibility) + if (i === commentItems.length - 1) { + core.setOutput('comment_id', comment.id); + core.setOutput('comment_url', comment.html_url); + } + } catch (error) { + console.error(`✗ Failed to create comment:`, error instanceof Error ? error.message : String(error)); + throw error; } } - if (!issueNumber) { - console.log('Could not determine issue or pull request number'); - return; + // Write summary for all created comments + if (createdComments.length > 0) { + let summaryContent = '\n\n## GitHub Comments\n'; + for (const comment of createdComments) { + summaryContent += `- Comment #${comment.id}: [View Comment](${comment.html_url})\n`; + } + await core.summary.addRaw(summaryContent).write(); } - // Extract body from the JSON item - let body = commentItem.body.trim(); - // Add AI disclaimer with run id, run htmlurl - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - body += `\n\n> Generated by Agentic Workflow Run [${runId}](${runUrl})\n`; - console.log(`Creating comment on ${commentEndpoint} #${issueNumber}`); - console.log('Comment content length:', body.length); - // Create the comment using GitHub API - const { data: comment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body: body - }); - console.log('Created comment #' + comment.id + ': ' + comment.html_url); - // Set output for other jobs to use - core.setOutput('comment_id', comment.id); - core.setOutput('comment_url', comment.html_url); - // write comment id, url to the github_step_summary - await core.summary.addRaw(` - ## GitHub Comment - - Comment ID: ${comment.id} - - Comment URL: ${comment.html_url} - `).write(); + console.log(`Successfully created ${createdComments.length} comment(s)`); + return createdComments; } await main(); diff --git a/.github/workflows/test-claude-create-issue.lock.yml b/.github/workflows/test-claude-create-issue.lock.yml index 96785e1245f..e4032116904 100644 --- a/.github/workflows/test-claude-create-issue.lock.yml +++ b/.github/workflows/test-claude-create-issue.lock.yml @@ -257,113 +257,138 @@ jobs: GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"create-issue\":true}" with: script: | - /** - * Sanitizes content for safe output in GitHub Actions - * @param {string} content - The content to sanitize - * @returns {string} The sanitized content - */ - function sanitizeContent(content) { - if (!content || typeof content !== 'string') { - return ''; - } - // Read allowed domains from environment variable - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = [ - 'github.com', - 'github.io', - 'githubusercontent.com', - 'githubassets.com', - 'github.dev', - 'codespaces.new' - ]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - // Neutralize @mentions to prevent unintended notifications - sanitized = neutralizeMentions(sanitized); - // Remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); - // XML character escaping - sanitized = sanitized - .replace(/&/g, '&') // Must be first to avoid double-escaping - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - // URI filtering - replace non-https protocols with "(redacted)" - sanitized = sanitizeUrlProtocols(sanitized); - // Domain filtering for HTTPS URIs - sanitized = sanitizeUrlDomains(sanitized); - // Limit total length to prevent DoS (0.5MB max) - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; - } - // Limit number of lines to prevent log flooding (65k max) - const lines = sanitized.split('\n'); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; - } - // Remove ANSI escape sequences - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); - // Neutralize common bot trigger phrases - sanitized = neutralizeBotTriggers(sanitized); - // Trim excessive whitespace - return sanitized.trim(); + async function main() { + const fs = require("fs"); /** - * Remove unknown domains - * @param {string} s - The string to process - * @returns {string} The string with unknown domains redacted + * Sanitizes content for safe output in GitHub Actions + * @param {string} content - The content to sanitize + * @returns {string} The sanitized content */ - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { - // Extract the hostname part (before first slash, colon, or other delimiter) - const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); - // Check if this domain or any parent domain is in the allowlist - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + function sanitizeContent(content) { + if (!content || typeof content !== 'string') { + return ''; + } + // Read allowed domains from environment variable + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = [ + 'github.com', + 'github.io', + 'githubusercontent.com', + 'githubassets.com', + 'github.dev', + 'codespaces.new' + ]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + // Neutralize @mentions to prevent unintended notifications + sanitized = neutralizeMentions(sanitized); + // Remove control characters (except newlines and tabs) + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); + // XML character escaping + sanitized = sanitized + .replace(/&/g, '&') // Must be first to avoid double-escaping + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + // URI filtering - replace non-https protocols with "(redacted)" + sanitized = sanitizeUrlProtocols(sanitized); + // Domain filtering for HTTPS URIs + sanitized = sanitizeUrlDomains(sanitized); + // Limit total length to prevent DoS (0.5MB max) + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; + } + // Limit number of lines to prevent log flooding (65k max) + const lines = sanitized.split('\n'); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; + } + // Remove ANSI escape sequences + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); + // Neutralize common bot trigger phrases + sanitized = neutralizeBotTriggers(sanitized); + // Trim excessive whitespace + return sanitized.trim(); + /** + * Remove unknown domains + * @param {string} s - The string to process + * @returns {string} The string with unknown domains redacted + */ + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { + // Extract the hostname part (before first slash, colon, or other delimiter) + const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); + // Check if this domain or any parent domain is in the allowlist + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + }); + return isAllowed ? match : '(redacted)'; }); - return isAllowed ? match : '(redacted)'; - }); - } - /** - * Remove unknown protocols except https - * @param {string} s - The string to process - * @returns {string} The string with non-https protocols redacted - */ - function sanitizeUrlProtocols(s) { - // Match both protocol:// and protocol: patterns - return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - // Allow https (case insensitive), redact everything else - return protocol.toLowerCase() === 'https' ? match : '(redacted)'; - }); - } - /** - * Neutralizes @mentions by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized mentions - */ - function neutralizeMentions(s) { - // Replace @name or @org/team outside code with `@name` - return s.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}\``); + } + /** + * Remove unknown protocols except https + * @param {string} s - The string to process + * @returns {string} The string with non-https protocols redacted + */ + function sanitizeUrlProtocols(s) { + // Match both protocol:// and protocol: patterns + return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + // Allow https (case insensitive), redact everything else + return protocol.toLowerCase() === 'https' ? match : '(redacted)'; + }); + } + /** + * Neutralizes @mentions by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized mentions + */ + function neutralizeMentions(s) { + // Replace @name or @org/team outside code with `@name` + return s.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}\``); + } + /** + * Neutralizes bot trigger phrases by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized bot triggers + */ + function neutralizeBotTriggers(s) { + // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, + (match, action, ref) => `\`${action} #${ref}\``); + } } /** - * Neutralizes bot trigger phrases by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized bot triggers + * Gets the maximum allowed count for a given output type + * @param {string} itemType - The output item type + * @param {Object} config - The safe-outputs configuration + * @returns {number} The maximum allowed count */ - function neutralizeBotTriggers(s) { - // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, - (match, action, ref) => `\`${action} #${ref}\``); + function getMaxAllowedForType(itemType, config) { + // Check if max is explicitly specified in config + if (config && config[itemType] && typeof config[itemType] === 'object' && config[itemType].max) { + return config[itemType].max; + } + // Use default limits for plural-supported types + switch (itemType) { + case 'create-issue': + return 10; // Allow multiple issues + case 'add-issue-comment': + return 10; // Allow multiple comments + case 'create-pull-request': + return 1; // Only one pull request allowed + case 'add-issue-labels': + return 1; // Only one labels operation allowed + default: + return 1; // Default to single item for unknown types + } } - } - async function main() { - const fs = require("fs"); const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; if (!outputFile) { @@ -413,10 +438,11 @@ jobs: errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(', ')}`); continue; } - // Check for duplicates based on type (since current format allows only one of each) - const existingItem = parsedItems.find(existing => existing.type === itemType); - if (existingItem) { - errors.push(`Line ${i + 1}: Duplicate output type '${itemType}'. Only one item of each type is currently allowed.`); + // Check for too many items of the same type + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); continue; } // Basic validation based on type @@ -918,83 +944,101 @@ jobs: console.log('No valid items found in agent output'); return; } - // Find the create-issue item - const createIssueItem = validatedOutput.items.find(/** @param {any} item */ item => item.type === 'create-issue'); - if (!createIssueItem) { - console.log('No create-issue item found in agent output'); + // Find all create-issue items + const createIssueItems = validatedOutput.items.filter(/** @param {any} item */ item => item.type === 'create-issue'); + if (createIssueItems.length === 0) { + console.log('No create-issue items found in agent output'); return; } - console.log('Found create-issue item:', { title: createIssueItem.title, bodyLength: createIssueItem.body.length }); + console.log(`Found ${createIssueItems.length} create-issue item(s)`); // Check if we're in an issue context (triggered by an issue event) const parentIssueNumber = context.payload?.issue?.number; - // Parse labels from environment variable (comma-separated string) or from the JSON item + // Parse labels from environment variable (comma-separated string) const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let labels = labelsEnv ? labelsEnv.split(',').map(/** @param {string} label */ label => label.trim()).filter(/** @param {string} label */ label => label) : []; - // If the item has labels, use those instead (or merge them) - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels].filter(Boolean); - } - // Extract title and body from the JSON item - let title = createIssueItem.title ? createIssueItem.title.trim() : ''; - let bodyLines = createIssueItem.body.split('\n'); - // If no title was found, use the body content as title (or a default) - if (!title) { - title = createIssueItem.body || 'Agent Output'; - } - // Apply title prefix if provided via environment variable - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (parentIssueNumber) { - console.log('Detected issue context, parent issue #' + parentIssueNumber); - // Add reference to parent issue in the child issue body - bodyLines.push(`Related to #${parentIssueNumber}`); - } - // Add AI disclaimer with run id, run htmlurl - // Add AI disclaimer with workflow run information - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> Generated by Agentic Workflow Run [${runId}](${runUrl})`, ''); - // Prepare the body content - const body = bodyLines.join('\n').trim(); - console.log('Creating issue with title:', title); - console.log('Labels:', labels); - console.log('Body length:', body.length); - // Create the issue using GitHub API - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels - }); - console.log('Created issue #' + issue.number + ': ' + issue.html_url); - // If we have a parent issue, add a comment to it referencing the new child issue - if (parentIssueNumber) { + let envLabels = labelsEnv ? labelsEnv.split(',').map(/** @param {string} label */ label => label.trim()).filter(/** @param {string} label */ label => label) : []; + const createdIssues = []; + // Process each create-issue item + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + console.log(`Processing create-issue item ${i + 1}/${createIssueItems.length}:`, { title: createIssueItem.title, bodyLength: createIssueItem.body.length }); + // Merge environment labels with item-specific labels + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels].filter(Boolean); + } + // Extract title and body from the JSON item + let title = createIssueItem.title ? createIssueItem.title.trim() : ''; + let bodyLines = createIssueItem.body.split('\n'); + // If no title was found, use the body content as title (or a default) + if (!title) { + title = createIssueItem.body || 'Agent Output'; + } + // Apply title prefix if provided via environment variable + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + if (parentIssueNumber) { + console.log('Detected issue context, parent issue #' + parentIssueNumber); + // Add reference to parent issue in the child issue body + bodyLines.push(`Related to #${parentIssueNumber}`); + } + // Add AI disclaimer with run id, run htmlurl + // Add AI disclaimer with workflow run information + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> Generated by Agentic Workflow Run [${runId}](${runUrl})`, ''); + // Prepare the body content + const body = bodyLines.join('\n').trim(); + console.log('Creating issue with title:', title); + console.log('Labels:', labels); + console.log('Body length:', body.length); try { - await github.rest.issues.createComment({ + // Create the issue using GitHub API + const { data: issue } = await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}` + title: title, + body: body, + labels: labels }); - console.log('Added comment to parent issue #' + parentIssueNumber); + console.log('Created issue #' + issue.number + ': ' + issue.html_url); + createdIssues.push(issue); + // If we have a parent issue, add a comment to it referencing the new child issue + if (parentIssueNumber) { + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}` + }); + console.log('Added comment to parent issue #' + parentIssueNumber); + } catch (error) { + console.log('Warning: Could not add comment to parent issue:', error instanceof Error ? error.message : String(error)); + } + } + // Set output for the last created issue (for backward compatibility) + if (i === createIssueItems.length - 1) { + core.setOutput('issue_number', issue.number); + core.setOutput('issue_url', issue.html_url); + } } catch (error) { - console.log('Warning: Could not add comment to parent issue:', error instanceof Error ? error.message : String(error)); + console.error(`✗ Failed to create issue "${title}":`, error instanceof Error ? error.message : String(error)); + throw error; + } + } + // Write summary for all created issues + if (createdIssues.length > 0) { + let summaryContent = '\n\n## GitHub Issues\n'; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; } + await core.summary.addRaw(summaryContent).write(); } - // Set output for other jobs to use - core.setOutput('issue_number', issue.number); - core.setOutput('issue_url', issue.html_url); - // write issue to summary - await core.summary.addRaw(` - ## GitHub Issue - - Issue ID: ${issue.number} - - Issue URL: ${issue.html_url} - `).write(); + console.log(`Successfully created ${createdIssues.length} issue(s)`); } await main(); diff --git a/.github/workflows/test-claude-create-pull-request.lock.yml b/.github/workflows/test-claude-create-pull-request.lock.yml index 8ff2685f769..5e26ffc5b45 100644 --- a/.github/workflows/test-claude-create-pull-request.lock.yml +++ b/.github/workflows/test-claude-create-pull-request.lock.yml @@ -266,113 +266,138 @@ jobs: GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"create-pull-request\":true}" with: script: | - /** - * Sanitizes content for safe output in GitHub Actions - * @param {string} content - The content to sanitize - * @returns {string} The sanitized content - */ - function sanitizeContent(content) { - if (!content || typeof content !== 'string') { - return ''; - } - // Read allowed domains from environment variable - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = [ - 'github.com', - 'github.io', - 'githubusercontent.com', - 'githubassets.com', - 'github.dev', - 'codespaces.new' - ]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - // Neutralize @mentions to prevent unintended notifications - sanitized = neutralizeMentions(sanitized); - // Remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); - // XML character escaping - sanitized = sanitized - .replace(/&/g, '&') // Must be first to avoid double-escaping - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - // URI filtering - replace non-https protocols with "(redacted)" - sanitized = sanitizeUrlProtocols(sanitized); - // Domain filtering for HTTPS URIs - sanitized = sanitizeUrlDomains(sanitized); - // Limit total length to prevent DoS (0.5MB max) - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; - } - // Limit number of lines to prevent log flooding (65k max) - const lines = sanitized.split('\n'); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; - } - // Remove ANSI escape sequences - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); - // Neutralize common bot trigger phrases - sanitized = neutralizeBotTriggers(sanitized); - // Trim excessive whitespace - return sanitized.trim(); + async function main() { + const fs = require("fs"); /** - * Remove unknown domains - * @param {string} s - The string to process - * @returns {string} The string with unknown domains redacted + * Sanitizes content for safe output in GitHub Actions + * @param {string} content - The content to sanitize + * @returns {string} The sanitized content */ - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { - // Extract the hostname part (before first slash, colon, or other delimiter) - const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); - // Check if this domain or any parent domain is in the allowlist - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + function sanitizeContent(content) { + if (!content || typeof content !== 'string') { + return ''; + } + // Read allowed domains from environment variable + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = [ + 'github.com', + 'github.io', + 'githubusercontent.com', + 'githubassets.com', + 'github.dev', + 'codespaces.new' + ]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + // Neutralize @mentions to prevent unintended notifications + sanitized = neutralizeMentions(sanitized); + // Remove control characters (except newlines and tabs) + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); + // XML character escaping + sanitized = sanitized + .replace(/&/g, '&') // Must be first to avoid double-escaping + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + // URI filtering - replace non-https protocols with "(redacted)" + sanitized = sanitizeUrlProtocols(sanitized); + // Domain filtering for HTTPS URIs + sanitized = sanitizeUrlDomains(sanitized); + // Limit total length to prevent DoS (0.5MB max) + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; + } + // Limit number of lines to prevent log flooding (65k max) + const lines = sanitized.split('\n'); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; + } + // Remove ANSI escape sequences + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); + // Neutralize common bot trigger phrases + sanitized = neutralizeBotTriggers(sanitized); + // Trim excessive whitespace + return sanitized.trim(); + /** + * Remove unknown domains + * @param {string} s - The string to process + * @returns {string} The string with unknown domains redacted + */ + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { + // Extract the hostname part (before first slash, colon, or other delimiter) + const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); + // Check if this domain or any parent domain is in the allowlist + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + }); + return isAllowed ? match : '(redacted)'; }); - return isAllowed ? match : '(redacted)'; - }); - } - /** - * Remove unknown protocols except https - * @param {string} s - The string to process - * @returns {string} The string with non-https protocols redacted - */ - function sanitizeUrlProtocols(s) { - // Match both protocol:// and protocol: patterns - return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - // Allow https (case insensitive), redact everything else - return protocol.toLowerCase() === 'https' ? match : '(redacted)'; - }); - } - /** - * Neutralizes @mentions by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized mentions - */ - function neutralizeMentions(s) { - // Replace @name or @org/team outside code with `@name` - return s.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}\``); + } + /** + * Remove unknown protocols except https + * @param {string} s - The string to process + * @returns {string} The string with non-https protocols redacted + */ + function sanitizeUrlProtocols(s) { + // Match both protocol:// and protocol: patterns + return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + // Allow https (case insensitive), redact everything else + return protocol.toLowerCase() === 'https' ? match : '(redacted)'; + }); + } + /** + * Neutralizes @mentions by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized mentions + */ + function neutralizeMentions(s) { + // Replace @name or @org/team outside code with `@name` + return s.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}\``); + } + /** + * Neutralizes bot trigger phrases by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized bot triggers + */ + function neutralizeBotTriggers(s) { + // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, + (match, action, ref) => `\`${action} #${ref}\``); + } } /** - * Neutralizes bot trigger phrases by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized bot triggers + * Gets the maximum allowed count for a given output type + * @param {string} itemType - The output item type + * @param {Object} config - The safe-outputs configuration + * @returns {number} The maximum allowed count */ - function neutralizeBotTriggers(s) { - // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, - (match, action, ref) => `\`${action} #${ref}\``); + function getMaxAllowedForType(itemType, config) { + // Check if max is explicitly specified in config + if (config && config[itemType] && typeof config[itemType] === 'object' && config[itemType].max) { + return config[itemType].max; + } + // Use default limits for plural-supported types + switch (itemType) { + case 'create-issue': + return 10; // Allow multiple issues + case 'add-issue-comment': + return 10; // Allow multiple comments + case 'create-pull-request': + return 1; // Only one pull request allowed + case 'add-issue-labels': + return 1; // Only one labels operation allowed + default: + return 1; // Default to single item for unknown types + } } - } - async function main() { - const fs = require("fs"); const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; if (!outputFile) { @@ -422,10 +447,11 @@ jobs: errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(', ')}`); continue; } - // Check for duplicates based on type (since current format allows only one of each) - const existingItem = parsedItems.find(existing => existing.type === itemType); - if (existingItem) { - errors.push(`Line ${i + 1}: Duplicate output type '${itemType}'. Only one item of each type is currently allowed.`); + // Check for too many items of the same type + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); continue; } // Basic validation based on type diff --git a/.github/workflows/test-claude-mcp.lock.yml b/.github/workflows/test-claude-mcp.lock.yml index c71bafb03f9..83dc0737eee 100644 --- a/.github/workflows/test-claude-mcp.lock.yml +++ b/.github/workflows/test-claude-mcp.lock.yml @@ -450,113 +450,138 @@ jobs: GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"create-issue\":true}" with: script: | - /** - * Sanitizes content for safe output in GitHub Actions - * @param {string} content - The content to sanitize - * @returns {string} The sanitized content - */ - function sanitizeContent(content) { - if (!content || typeof content !== 'string') { - return ''; - } - // Read allowed domains from environment variable - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = [ - 'github.com', - 'github.io', - 'githubusercontent.com', - 'githubassets.com', - 'github.dev', - 'codespaces.new' - ]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - // Neutralize @mentions to prevent unintended notifications - sanitized = neutralizeMentions(sanitized); - // Remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); - // XML character escaping - sanitized = sanitized - .replace(/&/g, '&') // Must be first to avoid double-escaping - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - // URI filtering - replace non-https protocols with "(redacted)" - sanitized = sanitizeUrlProtocols(sanitized); - // Domain filtering for HTTPS URIs - sanitized = sanitizeUrlDomains(sanitized); - // Limit total length to prevent DoS (0.5MB max) - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; - } - // Limit number of lines to prevent log flooding (65k max) - const lines = sanitized.split('\n'); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; - } - // Remove ANSI escape sequences - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); - // Neutralize common bot trigger phrases - sanitized = neutralizeBotTriggers(sanitized); - // Trim excessive whitespace - return sanitized.trim(); + async function main() { + const fs = require("fs"); /** - * Remove unknown domains - * @param {string} s - The string to process - * @returns {string} The string with unknown domains redacted + * Sanitizes content for safe output in GitHub Actions + * @param {string} content - The content to sanitize + * @returns {string} The sanitized content */ - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { - // Extract the hostname part (before first slash, colon, or other delimiter) - const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); - // Check if this domain or any parent domain is in the allowlist - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + function sanitizeContent(content) { + if (!content || typeof content !== 'string') { + return ''; + } + // Read allowed domains from environment variable + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = [ + 'github.com', + 'github.io', + 'githubusercontent.com', + 'githubassets.com', + 'github.dev', + 'codespaces.new' + ]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + // Neutralize @mentions to prevent unintended notifications + sanitized = neutralizeMentions(sanitized); + // Remove control characters (except newlines and tabs) + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); + // XML character escaping + sanitized = sanitized + .replace(/&/g, '&') // Must be first to avoid double-escaping + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + // URI filtering - replace non-https protocols with "(redacted)" + sanitized = sanitizeUrlProtocols(sanitized); + // Domain filtering for HTTPS URIs + sanitized = sanitizeUrlDomains(sanitized); + // Limit total length to prevent DoS (0.5MB max) + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; + } + // Limit number of lines to prevent log flooding (65k max) + const lines = sanitized.split('\n'); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; + } + // Remove ANSI escape sequences + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); + // Neutralize common bot trigger phrases + sanitized = neutralizeBotTriggers(sanitized); + // Trim excessive whitespace + return sanitized.trim(); + /** + * Remove unknown domains + * @param {string} s - The string to process + * @returns {string} The string with unknown domains redacted + */ + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { + // Extract the hostname part (before first slash, colon, or other delimiter) + const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); + // Check if this domain or any parent domain is in the allowlist + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + }); + return isAllowed ? match : '(redacted)'; }); - return isAllowed ? match : '(redacted)'; - }); - } - /** - * Remove unknown protocols except https - * @param {string} s - The string to process - * @returns {string} The string with non-https protocols redacted - */ - function sanitizeUrlProtocols(s) { - // Match both protocol:// and protocol: patterns - return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - // Allow https (case insensitive), redact everything else - return protocol.toLowerCase() === 'https' ? match : '(redacted)'; - }); - } - /** - * Neutralizes @mentions by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized mentions - */ - function neutralizeMentions(s) { - // Replace @name or @org/team outside code with `@name` - return s.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}\``); + } + /** + * Remove unknown protocols except https + * @param {string} s - The string to process + * @returns {string} The string with non-https protocols redacted + */ + function sanitizeUrlProtocols(s) { + // Match both protocol:// and protocol: patterns + return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + // Allow https (case insensitive), redact everything else + return protocol.toLowerCase() === 'https' ? match : '(redacted)'; + }); + } + /** + * Neutralizes @mentions by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized mentions + */ + function neutralizeMentions(s) { + // Replace @name or @org/team outside code with `@name` + return s.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}\``); + } + /** + * Neutralizes bot trigger phrases by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized bot triggers + */ + function neutralizeBotTriggers(s) { + // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, + (match, action, ref) => `\`${action} #${ref}\``); + } } /** - * Neutralizes bot trigger phrases by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized bot triggers + * Gets the maximum allowed count for a given output type + * @param {string} itemType - The output item type + * @param {Object} config - The safe-outputs configuration + * @returns {number} The maximum allowed count */ - function neutralizeBotTriggers(s) { - // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, - (match, action, ref) => `\`${action} #${ref}\``); + function getMaxAllowedForType(itemType, config) { + // Check if max is explicitly specified in config + if (config && config[itemType] && typeof config[itemType] === 'object' && config[itemType].max) { + return config[itemType].max; + } + // Use default limits for plural-supported types + switch (itemType) { + case 'create-issue': + return 10; // Allow multiple issues + case 'add-issue-comment': + return 10; // Allow multiple comments + case 'create-pull-request': + return 1; // Only one pull request allowed + case 'add-issue-labels': + return 1; // Only one labels operation allowed + default: + return 1; // Default to single item for unknown types + } } - } - async function main() { - const fs = require("fs"); const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; if (!outputFile) { @@ -606,10 +631,11 @@ jobs: errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(', ')}`); continue; } - // Check for duplicates based on type (since current format allows only one of each) - const existingItem = parsedItems.find(existing => existing.type === itemType); - if (existingItem) { - errors.push(`Line ${i + 1}: Duplicate output type '${itemType}'. Only one item of each type is currently allowed.`); + // Check for too many items of the same type + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); continue; } // Basic validation based on type @@ -1109,83 +1135,101 @@ jobs: console.log('No valid items found in agent output'); return; } - // Find the create-issue item - const createIssueItem = validatedOutput.items.find(/** @param {any} item */ item => item.type === 'create-issue'); - if (!createIssueItem) { - console.log('No create-issue item found in agent output'); + // Find all create-issue items + const createIssueItems = validatedOutput.items.filter(/** @param {any} item */ item => item.type === 'create-issue'); + if (createIssueItems.length === 0) { + console.log('No create-issue items found in agent output'); return; } - console.log('Found create-issue item:', { title: createIssueItem.title, bodyLength: createIssueItem.body.length }); + console.log(`Found ${createIssueItems.length} create-issue item(s)`); // Check if we're in an issue context (triggered by an issue event) const parentIssueNumber = context.payload?.issue?.number; - // Parse labels from environment variable (comma-separated string) or from the JSON item + // Parse labels from environment variable (comma-separated string) const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let labels = labelsEnv ? labelsEnv.split(',').map(/** @param {string} label */ label => label.trim()).filter(/** @param {string} label */ label => label) : []; - // If the item has labels, use those instead (or merge them) - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels].filter(Boolean); - } - // Extract title and body from the JSON item - let title = createIssueItem.title ? createIssueItem.title.trim() : ''; - let bodyLines = createIssueItem.body.split('\n'); - // If no title was found, use the body content as title (or a default) - if (!title) { - title = createIssueItem.body || 'Agent Output'; - } - // Apply title prefix if provided via environment variable - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (parentIssueNumber) { - console.log('Detected issue context, parent issue #' + parentIssueNumber); - // Add reference to parent issue in the child issue body - bodyLines.push(`Related to #${parentIssueNumber}`); - } - // Add AI disclaimer with run id, run htmlurl - // Add AI disclaimer with workflow run information - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> Generated by Agentic Workflow Run [${runId}](${runUrl})`, ''); - // Prepare the body content - const body = bodyLines.join('\n').trim(); - console.log('Creating issue with title:', title); - console.log('Labels:', labels); - console.log('Body length:', body.length); - // Create the issue using GitHub API - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels - }); - console.log('Created issue #' + issue.number + ': ' + issue.html_url); - // If we have a parent issue, add a comment to it referencing the new child issue - if (parentIssueNumber) { + let envLabels = labelsEnv ? labelsEnv.split(',').map(/** @param {string} label */ label => label.trim()).filter(/** @param {string} label */ label => label) : []; + const createdIssues = []; + // Process each create-issue item + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + console.log(`Processing create-issue item ${i + 1}/${createIssueItems.length}:`, { title: createIssueItem.title, bodyLength: createIssueItem.body.length }); + // Merge environment labels with item-specific labels + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels].filter(Boolean); + } + // Extract title and body from the JSON item + let title = createIssueItem.title ? createIssueItem.title.trim() : ''; + let bodyLines = createIssueItem.body.split('\n'); + // If no title was found, use the body content as title (or a default) + if (!title) { + title = createIssueItem.body || 'Agent Output'; + } + // Apply title prefix if provided via environment variable + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + if (parentIssueNumber) { + console.log('Detected issue context, parent issue #' + parentIssueNumber); + // Add reference to parent issue in the child issue body + bodyLines.push(`Related to #${parentIssueNumber}`); + } + // Add AI disclaimer with run id, run htmlurl + // Add AI disclaimer with workflow run information + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> Generated by Agentic Workflow Run [${runId}](${runUrl})`, ''); + // Prepare the body content + const body = bodyLines.join('\n').trim(); + console.log('Creating issue with title:', title); + console.log('Labels:', labels); + console.log('Body length:', body.length); try { - await github.rest.issues.createComment({ + // Create the issue using GitHub API + const { data: issue } = await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}` + title: title, + body: body, + labels: labels }); - console.log('Added comment to parent issue #' + parentIssueNumber); + console.log('Created issue #' + issue.number + ': ' + issue.html_url); + createdIssues.push(issue); + // If we have a parent issue, add a comment to it referencing the new child issue + if (parentIssueNumber) { + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}` + }); + console.log('Added comment to parent issue #' + parentIssueNumber); + } catch (error) { + console.log('Warning: Could not add comment to parent issue:', error instanceof Error ? error.message : String(error)); + } + } + // Set output for the last created issue (for backward compatibility) + if (i === createIssueItems.length - 1) { + core.setOutput('issue_number', issue.number); + core.setOutput('issue_url', issue.html_url); + } } catch (error) { - console.log('Warning: Could not add comment to parent issue:', error instanceof Error ? error.message : String(error)); + console.error(`✗ Failed to create issue "${title}":`, error instanceof Error ? error.message : String(error)); + throw error; + } + } + // Write summary for all created issues + if (createdIssues.length > 0) { + let summaryContent = '\n\n## GitHub Issues\n'; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; } + await core.summary.addRaw(summaryContent).write(); } - // Set output for other jobs to use - core.setOutput('issue_number', issue.number); - core.setOutput('issue_url', issue.html_url); - // write issue to summary - await core.summary.addRaw(` - ## GitHub Issue - - Issue ID: ${issue.number} - - Issue URL: ${issue.html_url} - `).write(); + console.log(`Successfully created ${createdIssues.length} issue(s)`); } await main(); diff --git a/.github/workflows/test-codex-add-issue-comment.lock.yml b/.github/workflows/test-codex-add-issue-comment.lock.yml index e38d150af83..e27ab812d28 100644 --- a/.github/workflows/test-codex-add-issue-comment.lock.yml +++ b/.github/workflows/test-codex-add-issue-comment.lock.yml @@ -366,113 +366,138 @@ jobs: GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"add-issue-comment\":true}" with: script: | - /** - * Sanitizes content for safe output in GitHub Actions - * @param {string} content - The content to sanitize - * @returns {string} The sanitized content - */ - function sanitizeContent(content) { - if (!content || typeof content !== 'string') { - return ''; - } - // Read allowed domains from environment variable - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = [ - 'github.com', - 'github.io', - 'githubusercontent.com', - 'githubassets.com', - 'github.dev', - 'codespaces.new' - ]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - // Neutralize @mentions to prevent unintended notifications - sanitized = neutralizeMentions(sanitized); - // Remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); - // XML character escaping - sanitized = sanitized - .replace(/&/g, '&') // Must be first to avoid double-escaping - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - // URI filtering - replace non-https protocols with "(redacted)" - sanitized = sanitizeUrlProtocols(sanitized); - // Domain filtering for HTTPS URIs - sanitized = sanitizeUrlDomains(sanitized); - // Limit total length to prevent DoS (0.5MB max) - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; - } - // Limit number of lines to prevent log flooding (65k max) - const lines = sanitized.split('\n'); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; - } - // Remove ANSI escape sequences - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); - // Neutralize common bot trigger phrases - sanitized = neutralizeBotTriggers(sanitized); - // Trim excessive whitespace - return sanitized.trim(); + async function main() { + const fs = require("fs"); /** - * Remove unknown domains - * @param {string} s - The string to process - * @returns {string} The string with unknown domains redacted + * Sanitizes content for safe output in GitHub Actions + * @param {string} content - The content to sanitize + * @returns {string} The sanitized content */ - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { - // Extract the hostname part (before first slash, colon, or other delimiter) - const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); - // Check if this domain or any parent domain is in the allowlist - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + function sanitizeContent(content) { + if (!content || typeof content !== 'string') { + return ''; + } + // Read allowed domains from environment variable + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = [ + 'github.com', + 'github.io', + 'githubusercontent.com', + 'githubassets.com', + 'github.dev', + 'codespaces.new' + ]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + // Neutralize @mentions to prevent unintended notifications + sanitized = neutralizeMentions(sanitized); + // Remove control characters (except newlines and tabs) + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); + // XML character escaping + sanitized = sanitized + .replace(/&/g, '&') // Must be first to avoid double-escaping + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + // URI filtering - replace non-https protocols with "(redacted)" + sanitized = sanitizeUrlProtocols(sanitized); + // Domain filtering for HTTPS URIs + sanitized = sanitizeUrlDomains(sanitized); + // Limit total length to prevent DoS (0.5MB max) + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; + } + // Limit number of lines to prevent log flooding (65k max) + const lines = sanitized.split('\n'); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; + } + // Remove ANSI escape sequences + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); + // Neutralize common bot trigger phrases + sanitized = neutralizeBotTriggers(sanitized); + // Trim excessive whitespace + return sanitized.trim(); + /** + * Remove unknown domains + * @param {string} s - The string to process + * @returns {string} The string with unknown domains redacted + */ + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { + // Extract the hostname part (before first slash, colon, or other delimiter) + const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); + // Check if this domain or any parent domain is in the allowlist + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + }); + return isAllowed ? match : '(redacted)'; }); - return isAllowed ? match : '(redacted)'; - }); - } - /** - * Remove unknown protocols except https - * @param {string} s - The string to process - * @returns {string} The string with non-https protocols redacted - */ - function sanitizeUrlProtocols(s) { - // Match both protocol:// and protocol: patterns - return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - // Allow https (case insensitive), redact everything else - return protocol.toLowerCase() === 'https' ? match : '(redacted)'; - }); - } - /** - * Neutralizes @mentions by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized mentions - */ - function neutralizeMentions(s) { - // Replace @name or @org/team outside code with `@name` - return s.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}\``); + } + /** + * Remove unknown protocols except https + * @param {string} s - The string to process + * @returns {string} The string with non-https protocols redacted + */ + function sanitizeUrlProtocols(s) { + // Match both protocol:// and protocol: patterns + return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + // Allow https (case insensitive), redact everything else + return protocol.toLowerCase() === 'https' ? match : '(redacted)'; + }); + } + /** + * Neutralizes @mentions by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized mentions + */ + function neutralizeMentions(s) { + // Replace @name or @org/team outside code with `@name` + return s.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}\``); + } + /** + * Neutralizes bot trigger phrases by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized bot triggers + */ + function neutralizeBotTriggers(s) { + // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, + (match, action, ref) => `\`${action} #${ref}\``); + } } /** - * Neutralizes bot trigger phrases by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized bot triggers + * Gets the maximum allowed count for a given output type + * @param {string} itemType - The output item type + * @param {Object} config - The safe-outputs configuration + * @returns {number} The maximum allowed count */ - function neutralizeBotTriggers(s) { - // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, - (match, action, ref) => `\`${action} #${ref}\``); + function getMaxAllowedForType(itemType, config) { + // Check if max is explicitly specified in config + if (config && config[itemType] && typeof config[itemType] === 'object' && config[itemType].max) { + return config[itemType].max; + } + // Use default limits for plural-supported types + switch (itemType) { + case 'create-issue': + return 10; // Allow multiple issues + case 'add-issue-comment': + return 10; // Allow multiple comments + case 'create-pull-request': + return 1; // Only one pull request allowed + case 'add-issue-labels': + return 1; // Only one labels operation allowed + default: + return 1; // Default to single item for unknown types + } } - } - async function main() { - const fs = require("fs"); const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; if (!outputFile) { @@ -522,10 +547,11 @@ jobs: errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(', ')}`); continue; } - // Check for duplicates based on type (since current format allows only one of each) - const existingItem = parsedItems.find(existing => existing.type === itemType); - if (existingItem) { - errors.push(`Line ${i + 1}: Duplicate output type '${itemType}'. Only one item of each type is currently allowed.`); + // Check for too many items of the same type + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); continue; } // Basic validation based on type @@ -972,13 +998,13 @@ jobs: console.log('No valid items found in agent output'); return; } - // Find the add-issue-comment item - const commentItem = validatedOutput.items.find(/** @param {any} item */ item => item.type === 'add-issue-comment'); - if (!commentItem) { - console.log('No add-issue-comment item found in agent output'); + // Find all add-issue-comment items + const commentItems = validatedOutput.items.filter(/** @param {any} item */ item => item.type === 'add-issue-comment'); + if (commentItems.length === 0) { + console.log('No add-issue-comment items found in agent output'); return; } - console.log('Found add-issue-comment item:', { bodyLength: commentItem.body.length }); + console.log(`Found ${commentItems.length} add-issue-comment item(s)`); // Check if we're in an issue or pull request context 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'; @@ -986,57 +1012,75 @@ jobs: console.log('Not running in issue or pull request context, skipping comment creation'); return; } - // Determine the issue/PR number and comment endpoint - let issueNumber; - let commentEndpoint; - if (isIssueContext) { - if (context.payload.issue) { - issueNumber = context.payload.issue.number; - commentEndpoint = 'issues'; - } else { - console.log('Issue context detected but no issue found in payload'); - return; + const createdComments = []; + // Process each comment item + for (let i = 0; i < commentItems.length; i++) { + const commentItem = commentItems[i]; + console.log(`Processing add-issue-comment item ${i + 1}/${commentItems.length}:`, { bodyLength: commentItem.body.length }); + // Determine the issue/PR number and comment endpoint for this comment + let issueNumber; + let commentEndpoint; + if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; + commentEndpoint = 'issues'; + } else { + console.log('Issue context detected but no issue found in payload'); + continue; + } + } else if (isPRContext) { + if (context.payload.pull_request) { + issueNumber = context.payload.pull_request.number; + commentEndpoint = 'issues'; // PR comments use the issues API endpoint + } else { + console.log('Pull request context detected but no pull request found in payload'); + continue; + } } - } else if (isPRContext) { - if (context.payload.pull_request) { - issueNumber = context.payload.pull_request.number; - commentEndpoint = 'issues'; // PR comments use the issues API endpoint - } else { - console.log('Pull request context detected but no pull request found in payload'); - return; + if (!issueNumber) { + console.log('Could not determine issue or pull request number'); + continue; + } + // Extract body from the JSON item + let body = commentItem.body.trim(); + // Add AI disclaimer with run id, run htmlurl + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + body += `\n\n> Generated by Agentic Workflow Run [${runId}](${runUrl})\n`; + console.log(`Creating comment on ${commentEndpoint} #${issueNumber}`); + console.log('Comment content length:', body.length); + try { + // Create the comment using GitHub API + const { data: comment } = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: body + }); + console.log('Created comment #' + comment.id + ': ' + comment.html_url); + createdComments.push(comment); + // Set output for the last created comment (for backward compatibility) + if (i === commentItems.length - 1) { + core.setOutput('comment_id', comment.id); + core.setOutput('comment_url', comment.html_url); + } + } catch (error) { + console.error(`✗ Failed to create comment:`, error instanceof Error ? error.message : String(error)); + throw error; } } - if (!issueNumber) { - console.log('Could not determine issue or pull request number'); - return; + // Write summary for all created comments + if (createdComments.length > 0) { + let summaryContent = '\n\n## GitHub Comments\n'; + for (const comment of createdComments) { + summaryContent += `- Comment #${comment.id}: [View Comment](${comment.html_url})\n`; + } + await core.summary.addRaw(summaryContent).write(); } - // Extract body from the JSON item - let body = commentItem.body.trim(); - // Add AI disclaimer with run id, run htmlurl - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - body += `\n\n> Generated by Agentic Workflow Run [${runId}](${runUrl})\n`; - console.log(`Creating comment on ${commentEndpoint} #${issueNumber}`); - console.log('Comment content length:', body.length); - // Create the comment using GitHub API - const { data: comment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body: body - }); - console.log('Created comment #' + comment.id + ': ' + comment.html_url); - // Set output for other jobs to use - core.setOutput('comment_id', comment.id); - core.setOutput('comment_url', comment.html_url); - // write comment id, url to the github_step_summary - await core.summary.addRaw(` - ## GitHub Comment - - Comment ID: ${comment.id} - - Comment URL: ${comment.html_url} - `).write(); + console.log(`Successfully created ${createdComments.length} comment(s)`); + return createdComments; } await main(); diff --git a/.github/workflows/test-codex-add-issue-labels.lock.yml b/.github/workflows/test-codex-add-issue-labels.lock.yml index 6a0a16b5f79..7213691c7d3 100644 --- a/.github/workflows/test-codex-add-issue-labels.lock.yml +++ b/.github/workflows/test-codex-add-issue-labels.lock.yml @@ -366,113 +366,138 @@ jobs: GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"add-issue-labels\":true}" with: script: | - /** - * Sanitizes content for safe output in GitHub Actions - * @param {string} content - The content to sanitize - * @returns {string} The sanitized content - */ - function sanitizeContent(content) { - if (!content || typeof content !== 'string') { - return ''; - } - // Read allowed domains from environment variable - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = [ - 'github.com', - 'github.io', - 'githubusercontent.com', - 'githubassets.com', - 'github.dev', - 'codespaces.new' - ]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - // Neutralize @mentions to prevent unintended notifications - sanitized = neutralizeMentions(sanitized); - // Remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); - // XML character escaping - sanitized = sanitized - .replace(/&/g, '&') // Must be first to avoid double-escaping - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - // URI filtering - replace non-https protocols with "(redacted)" - sanitized = sanitizeUrlProtocols(sanitized); - // Domain filtering for HTTPS URIs - sanitized = sanitizeUrlDomains(sanitized); - // Limit total length to prevent DoS (0.5MB max) - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; - } - // Limit number of lines to prevent log flooding (65k max) - const lines = sanitized.split('\n'); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; - } - // Remove ANSI escape sequences - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); - // Neutralize common bot trigger phrases - sanitized = neutralizeBotTriggers(sanitized); - // Trim excessive whitespace - return sanitized.trim(); + async function main() { + const fs = require("fs"); /** - * Remove unknown domains - * @param {string} s - The string to process - * @returns {string} The string with unknown domains redacted + * Sanitizes content for safe output in GitHub Actions + * @param {string} content - The content to sanitize + * @returns {string} The sanitized content */ - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { - // Extract the hostname part (before first slash, colon, or other delimiter) - const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); - // Check if this domain or any parent domain is in the allowlist - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + function sanitizeContent(content) { + if (!content || typeof content !== 'string') { + return ''; + } + // Read allowed domains from environment variable + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = [ + 'github.com', + 'github.io', + 'githubusercontent.com', + 'githubassets.com', + 'github.dev', + 'codespaces.new' + ]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + // Neutralize @mentions to prevent unintended notifications + sanitized = neutralizeMentions(sanitized); + // Remove control characters (except newlines and tabs) + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); + // XML character escaping + sanitized = sanitized + .replace(/&/g, '&') // Must be first to avoid double-escaping + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + // URI filtering - replace non-https protocols with "(redacted)" + sanitized = sanitizeUrlProtocols(sanitized); + // Domain filtering for HTTPS URIs + sanitized = sanitizeUrlDomains(sanitized); + // Limit total length to prevent DoS (0.5MB max) + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; + } + // Limit number of lines to prevent log flooding (65k max) + const lines = sanitized.split('\n'); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; + } + // Remove ANSI escape sequences + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); + // Neutralize common bot trigger phrases + sanitized = neutralizeBotTriggers(sanitized); + // Trim excessive whitespace + return sanitized.trim(); + /** + * Remove unknown domains + * @param {string} s - The string to process + * @returns {string} The string with unknown domains redacted + */ + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { + // Extract the hostname part (before first slash, colon, or other delimiter) + const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); + // Check if this domain or any parent domain is in the allowlist + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + }); + return isAllowed ? match : '(redacted)'; }); - return isAllowed ? match : '(redacted)'; - }); - } - /** - * Remove unknown protocols except https - * @param {string} s - The string to process - * @returns {string} The string with non-https protocols redacted - */ - function sanitizeUrlProtocols(s) { - // Match both protocol:// and protocol: patterns - return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - // Allow https (case insensitive), redact everything else - return protocol.toLowerCase() === 'https' ? match : '(redacted)'; - }); - } - /** - * Neutralizes @mentions by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized mentions - */ - function neutralizeMentions(s) { - // Replace @name or @org/team outside code with `@name` - return s.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}\``); + } + /** + * Remove unknown protocols except https + * @param {string} s - The string to process + * @returns {string} The string with non-https protocols redacted + */ + function sanitizeUrlProtocols(s) { + // Match both protocol:// and protocol: patterns + return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + // Allow https (case insensitive), redact everything else + return protocol.toLowerCase() === 'https' ? match : '(redacted)'; + }); + } + /** + * Neutralizes @mentions by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized mentions + */ + function neutralizeMentions(s) { + // Replace @name or @org/team outside code with `@name` + return s.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}\``); + } + /** + * Neutralizes bot trigger phrases by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized bot triggers + */ + function neutralizeBotTriggers(s) { + // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, + (match, action, ref) => `\`${action} #${ref}\``); + } } /** - * Neutralizes bot trigger phrases by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized bot triggers + * Gets the maximum allowed count for a given output type + * @param {string} itemType - The output item type + * @param {Object} config - The safe-outputs configuration + * @returns {number} The maximum allowed count */ - function neutralizeBotTriggers(s) { - // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, - (match, action, ref) => `\`${action} #${ref}\``); + function getMaxAllowedForType(itemType, config) { + // Check if max is explicitly specified in config + if (config && config[itemType] && typeof config[itemType] === 'object' && config[itemType].max) { + return config[itemType].max; + } + // Use default limits for plural-supported types + switch (itemType) { + case 'create-issue': + return 10; // Allow multiple issues + case 'add-issue-comment': + return 10; // Allow multiple comments + case 'create-pull-request': + return 1; // Only one pull request allowed + case 'add-issue-labels': + return 1; // Only one labels operation allowed + default: + return 1; // Default to single item for unknown types + } } - } - async function main() { - const fs = require("fs"); const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; if (!outputFile) { @@ -522,10 +547,11 @@ jobs: errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(', ')}`); continue; } - // Check for duplicates based on type (since current format allows only one of each) - const existingItem = parsedItems.find(existing => existing.type === itemType); - if (existingItem) { - errors.push(`Line ${i + 1}: Duplicate output type '${itemType}'. Only one item of each type is currently allowed.`); + // Check for too many items of the same type + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); continue; } // Basic validation based on type diff --git a/.github/workflows/test-codex-alias.lock.yml b/.github/workflows/test-codex-alias.lock.yml index bbbcb75b92d..b9793b8b93b 100644 --- a/.github/workflows/test-codex-alias.lock.yml +++ b/.github/workflows/test-codex-alias.lock.yml @@ -666,113 +666,138 @@ jobs: GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"add-issue-comment\":true}" with: script: | - /** - * Sanitizes content for safe output in GitHub Actions - * @param {string} content - The content to sanitize - * @returns {string} The sanitized content - */ - function sanitizeContent(content) { - if (!content || typeof content !== 'string') { - return ''; - } - // Read allowed domains from environment variable - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = [ - 'github.com', - 'github.io', - 'githubusercontent.com', - 'githubassets.com', - 'github.dev', - 'codespaces.new' - ]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - // Neutralize @mentions to prevent unintended notifications - sanitized = neutralizeMentions(sanitized); - // Remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); - // XML character escaping - sanitized = sanitized - .replace(/&/g, '&') // Must be first to avoid double-escaping - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - // URI filtering - replace non-https protocols with "(redacted)" - sanitized = sanitizeUrlProtocols(sanitized); - // Domain filtering for HTTPS URIs - sanitized = sanitizeUrlDomains(sanitized); - // Limit total length to prevent DoS (0.5MB max) - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; - } - // Limit number of lines to prevent log flooding (65k max) - const lines = sanitized.split('\n'); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; - } - // Remove ANSI escape sequences - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); - // Neutralize common bot trigger phrases - sanitized = neutralizeBotTriggers(sanitized); - // Trim excessive whitespace - return sanitized.trim(); + async function main() { + const fs = require("fs"); /** - * Remove unknown domains - * @param {string} s - The string to process - * @returns {string} The string with unknown domains redacted + * Sanitizes content for safe output in GitHub Actions + * @param {string} content - The content to sanitize + * @returns {string} The sanitized content */ - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { - // Extract the hostname part (before first slash, colon, or other delimiter) - const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); - // Check if this domain or any parent domain is in the allowlist - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + function sanitizeContent(content) { + if (!content || typeof content !== 'string') { + return ''; + } + // Read allowed domains from environment variable + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = [ + 'github.com', + 'github.io', + 'githubusercontent.com', + 'githubassets.com', + 'github.dev', + 'codespaces.new' + ]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + // Neutralize @mentions to prevent unintended notifications + sanitized = neutralizeMentions(sanitized); + // Remove control characters (except newlines and tabs) + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); + // XML character escaping + sanitized = sanitized + .replace(/&/g, '&') // Must be first to avoid double-escaping + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + // URI filtering - replace non-https protocols with "(redacted)" + sanitized = sanitizeUrlProtocols(sanitized); + // Domain filtering for HTTPS URIs + sanitized = sanitizeUrlDomains(sanitized); + // Limit total length to prevent DoS (0.5MB max) + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; + } + // Limit number of lines to prevent log flooding (65k max) + const lines = sanitized.split('\n'); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; + } + // Remove ANSI escape sequences + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); + // Neutralize common bot trigger phrases + sanitized = neutralizeBotTriggers(sanitized); + // Trim excessive whitespace + return sanitized.trim(); + /** + * Remove unknown domains + * @param {string} s - The string to process + * @returns {string} The string with unknown domains redacted + */ + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { + // Extract the hostname part (before first slash, colon, or other delimiter) + const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); + // Check if this domain or any parent domain is in the allowlist + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + }); + return isAllowed ? match : '(redacted)'; }); - return isAllowed ? match : '(redacted)'; - }); - } - /** - * Remove unknown protocols except https - * @param {string} s - The string to process - * @returns {string} The string with non-https protocols redacted - */ - function sanitizeUrlProtocols(s) { - // Match both protocol:// and protocol: patterns - return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - // Allow https (case insensitive), redact everything else - return protocol.toLowerCase() === 'https' ? match : '(redacted)'; - }); - } - /** - * Neutralizes @mentions by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized mentions - */ - function neutralizeMentions(s) { - // Replace @name or @org/team outside code with `@name` - return s.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}\``); + } + /** + * Remove unknown protocols except https + * @param {string} s - The string to process + * @returns {string} The string with non-https protocols redacted + */ + function sanitizeUrlProtocols(s) { + // Match both protocol:// and protocol: patterns + return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + // Allow https (case insensitive), redact everything else + return protocol.toLowerCase() === 'https' ? match : '(redacted)'; + }); + } + /** + * Neutralizes @mentions by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized mentions + */ + function neutralizeMentions(s) { + // Replace @name or @org/team outside code with `@name` + return s.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}\``); + } + /** + * Neutralizes bot trigger phrases by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized bot triggers + */ + function neutralizeBotTriggers(s) { + // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, + (match, action, ref) => `\`${action} #${ref}\``); + } } /** - * Neutralizes bot trigger phrases by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized bot triggers + * Gets the maximum allowed count for a given output type + * @param {string} itemType - The output item type + * @param {Object} config - The safe-outputs configuration + * @returns {number} The maximum allowed count */ - function neutralizeBotTriggers(s) { - // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, - (match, action, ref) => `\`${action} #${ref}\``); + function getMaxAllowedForType(itemType, config) { + // Check if max is explicitly specified in config + if (config && config[itemType] && typeof config[itemType] === 'object' && config[itemType].max) { + return config[itemType].max; + } + // Use default limits for plural-supported types + switch (itemType) { + case 'create-issue': + return 10; // Allow multiple issues + case 'add-issue-comment': + return 10; // Allow multiple comments + case 'create-pull-request': + return 1; // Only one pull request allowed + case 'add-issue-labels': + return 1; // Only one labels operation allowed + default: + return 1; // Default to single item for unknown types + } } - } - async function main() { - const fs = require("fs"); const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; if (!outputFile) { @@ -822,10 +847,11 @@ jobs: errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(', ')}`); continue; } - // Check for duplicates based on type (since current format allows only one of each) - const existingItem = parsedItems.find(existing => existing.type === itemType); - if (existingItem) { - errors.push(`Line ${i + 1}: Duplicate output type '${itemType}'. Only one item of each type is currently allowed.`); + // Check for too many items of the same type + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); continue; } // Basic validation based on type @@ -1327,13 +1353,13 @@ jobs: console.log('No valid items found in agent output'); return; } - // Find the add-issue-comment item - const commentItem = validatedOutput.items.find(/** @param {any} item */ item => item.type === 'add-issue-comment'); - if (!commentItem) { - console.log('No add-issue-comment item found in agent output'); + // Find all add-issue-comment items + const commentItems = validatedOutput.items.filter(/** @param {any} item */ item => item.type === 'add-issue-comment'); + if (commentItems.length === 0) { + console.log('No add-issue-comment items found in agent output'); return; } - console.log('Found add-issue-comment item:', { bodyLength: commentItem.body.length }); + console.log(`Found ${commentItems.length} add-issue-comment item(s)`); // Check if we're in an issue or pull request context 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'; @@ -1341,57 +1367,75 @@ jobs: console.log('Not running in issue or pull request context, skipping comment creation'); return; } - // Determine the issue/PR number and comment endpoint - let issueNumber; - let commentEndpoint; - if (isIssueContext) { - if (context.payload.issue) { - issueNumber = context.payload.issue.number; - commentEndpoint = 'issues'; - } else { - console.log('Issue context detected but no issue found in payload'); - return; + const createdComments = []; + // Process each comment item + for (let i = 0; i < commentItems.length; i++) { + const commentItem = commentItems[i]; + console.log(`Processing add-issue-comment item ${i + 1}/${commentItems.length}:`, { bodyLength: commentItem.body.length }); + // Determine the issue/PR number and comment endpoint for this comment + let issueNumber; + let commentEndpoint; + if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; + commentEndpoint = 'issues'; + } else { + console.log('Issue context detected but no issue found in payload'); + continue; + } + } else if (isPRContext) { + if (context.payload.pull_request) { + issueNumber = context.payload.pull_request.number; + commentEndpoint = 'issues'; // PR comments use the issues API endpoint + } else { + console.log('Pull request context detected but no pull request found in payload'); + continue; + } } - } else if (isPRContext) { - if (context.payload.pull_request) { - issueNumber = context.payload.pull_request.number; - commentEndpoint = 'issues'; // PR comments use the issues API endpoint - } else { - console.log('Pull request context detected but no pull request found in payload'); - return; + if (!issueNumber) { + console.log('Could not determine issue or pull request number'); + continue; + } + // Extract body from the JSON item + let body = commentItem.body.trim(); + // Add AI disclaimer with run id, run htmlurl + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + body += `\n\n> Generated by Agentic Workflow Run [${runId}](${runUrl})\n`; + console.log(`Creating comment on ${commentEndpoint} #${issueNumber}`); + console.log('Comment content length:', body.length); + try { + // Create the comment using GitHub API + const { data: comment } = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: body + }); + console.log('Created comment #' + comment.id + ': ' + comment.html_url); + createdComments.push(comment); + // Set output for the last created comment (for backward compatibility) + if (i === commentItems.length - 1) { + core.setOutput('comment_id', comment.id); + core.setOutput('comment_url', comment.html_url); + } + } catch (error) { + console.error(`✗ Failed to create comment:`, error instanceof Error ? error.message : String(error)); + throw error; } } - if (!issueNumber) { - console.log('Could not determine issue or pull request number'); - return; + // Write summary for all created comments + if (createdComments.length > 0) { + let summaryContent = '\n\n## GitHub Comments\n'; + for (const comment of createdComments) { + summaryContent += `- Comment #${comment.id}: [View Comment](${comment.html_url})\n`; + } + await core.summary.addRaw(summaryContent).write(); } - // Extract body from the JSON item - let body = commentItem.body.trim(); - // Add AI disclaimer with run id, run htmlurl - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - body += `\n\n> Generated by Agentic Workflow Run [${runId}](${runUrl})\n`; - console.log(`Creating comment on ${commentEndpoint} #${issueNumber}`); - console.log('Comment content length:', body.length); - // Create the comment using GitHub API - const { data: comment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body: body - }); - console.log('Created comment #' + comment.id + ': ' + comment.html_url); - // Set output for other jobs to use - core.setOutput('comment_id', comment.id); - core.setOutput('comment_url', comment.html_url); - // write comment id, url to the github_step_summary - await core.summary.addRaw(` - ## GitHub Comment - - Comment ID: ${comment.id} - - Comment URL: ${comment.html_url} - `).write(); + console.log(`Successfully created ${createdComments.length} comment(s)`); + return createdComments; } await main(); diff --git a/.github/workflows/test-codex-create-issue.lock.yml b/.github/workflows/test-codex-create-issue.lock.yml index 8f731cf8b02..21652888c48 100644 --- a/.github/workflows/test-codex-create-issue.lock.yml +++ b/.github/workflows/test-codex-create-issue.lock.yml @@ -195,113 +195,138 @@ jobs: GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"create-issue\":true}" with: script: | - /** - * Sanitizes content for safe output in GitHub Actions - * @param {string} content - The content to sanitize - * @returns {string} The sanitized content - */ - function sanitizeContent(content) { - if (!content || typeof content !== 'string') { - return ''; - } - // Read allowed domains from environment variable - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = [ - 'github.com', - 'github.io', - 'githubusercontent.com', - 'githubassets.com', - 'github.dev', - 'codespaces.new' - ]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - // Neutralize @mentions to prevent unintended notifications - sanitized = neutralizeMentions(sanitized); - // Remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); - // XML character escaping - sanitized = sanitized - .replace(/&/g, '&') // Must be first to avoid double-escaping - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - // URI filtering - replace non-https protocols with "(redacted)" - sanitized = sanitizeUrlProtocols(sanitized); - // Domain filtering for HTTPS URIs - sanitized = sanitizeUrlDomains(sanitized); - // Limit total length to prevent DoS (0.5MB max) - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; - } - // Limit number of lines to prevent log flooding (65k max) - const lines = sanitized.split('\n'); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; - } - // Remove ANSI escape sequences - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); - // Neutralize common bot trigger phrases - sanitized = neutralizeBotTriggers(sanitized); - // Trim excessive whitespace - return sanitized.trim(); + async function main() { + const fs = require("fs"); /** - * Remove unknown domains - * @param {string} s - The string to process - * @returns {string} The string with unknown domains redacted + * Sanitizes content for safe output in GitHub Actions + * @param {string} content - The content to sanitize + * @returns {string} The sanitized content */ - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { - // Extract the hostname part (before first slash, colon, or other delimiter) - const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); - // Check if this domain or any parent domain is in the allowlist - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + function sanitizeContent(content) { + if (!content || typeof content !== 'string') { + return ''; + } + // Read allowed domains from environment variable + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = [ + 'github.com', + 'github.io', + 'githubusercontent.com', + 'githubassets.com', + 'github.dev', + 'codespaces.new' + ]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + // Neutralize @mentions to prevent unintended notifications + sanitized = neutralizeMentions(sanitized); + // Remove control characters (except newlines and tabs) + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); + // XML character escaping + sanitized = sanitized + .replace(/&/g, '&') // Must be first to avoid double-escaping + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + // URI filtering - replace non-https protocols with "(redacted)" + sanitized = sanitizeUrlProtocols(sanitized); + // Domain filtering for HTTPS URIs + sanitized = sanitizeUrlDomains(sanitized); + // Limit total length to prevent DoS (0.5MB max) + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; + } + // Limit number of lines to prevent log flooding (65k max) + const lines = sanitized.split('\n'); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; + } + // Remove ANSI escape sequences + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); + // Neutralize common bot trigger phrases + sanitized = neutralizeBotTriggers(sanitized); + // Trim excessive whitespace + return sanitized.trim(); + /** + * Remove unknown domains + * @param {string} s - The string to process + * @returns {string} The string with unknown domains redacted + */ + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { + // Extract the hostname part (before first slash, colon, or other delimiter) + const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); + // Check if this domain or any parent domain is in the allowlist + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + }); + return isAllowed ? match : '(redacted)'; }); - return isAllowed ? match : '(redacted)'; - }); - } - /** - * Remove unknown protocols except https - * @param {string} s - The string to process - * @returns {string} The string with non-https protocols redacted - */ - function sanitizeUrlProtocols(s) { - // Match both protocol:// and protocol: patterns - return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - // Allow https (case insensitive), redact everything else - return protocol.toLowerCase() === 'https' ? match : '(redacted)'; - }); - } - /** - * Neutralizes @mentions by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized mentions - */ - function neutralizeMentions(s) { - // Replace @name or @org/team outside code with `@name` - return s.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}\``); + } + /** + * Remove unknown protocols except https + * @param {string} s - The string to process + * @returns {string} The string with non-https protocols redacted + */ + function sanitizeUrlProtocols(s) { + // Match both protocol:// and protocol: patterns + return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + // Allow https (case insensitive), redact everything else + return protocol.toLowerCase() === 'https' ? match : '(redacted)'; + }); + } + /** + * Neutralizes @mentions by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized mentions + */ + function neutralizeMentions(s) { + // Replace @name or @org/team outside code with `@name` + return s.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}\``); + } + /** + * Neutralizes bot trigger phrases by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized bot triggers + */ + function neutralizeBotTriggers(s) { + // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, + (match, action, ref) => `\`${action} #${ref}\``); + } } /** - * Neutralizes bot trigger phrases by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized bot triggers + * Gets the maximum allowed count for a given output type + * @param {string} itemType - The output item type + * @param {Object} config - The safe-outputs configuration + * @returns {number} The maximum allowed count */ - function neutralizeBotTriggers(s) { - // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, - (match, action, ref) => `\`${action} #${ref}\``); + function getMaxAllowedForType(itemType, config) { + // Check if max is explicitly specified in config + if (config && config[itemType] && typeof config[itemType] === 'object' && config[itemType].max) { + return config[itemType].max; + } + // Use default limits for plural-supported types + switch (itemType) { + case 'create-issue': + return 10; // Allow multiple issues + case 'add-issue-comment': + return 10; // Allow multiple comments + case 'create-pull-request': + return 1; // Only one pull request allowed + case 'add-issue-labels': + return 1; // Only one labels operation allowed + default: + return 1; // Default to single item for unknown types + } } - } - async function main() { - const fs = require("fs"); const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; if (!outputFile) { @@ -351,10 +376,11 @@ jobs: errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(', ')}`); continue; } - // Check for duplicates based on type (since current format allows only one of each) - const existingItem = parsedItems.find(existing => existing.type === itemType); - if (existingItem) { - errors.push(`Line ${i + 1}: Duplicate output type '${itemType}'. Only one item of each type is currently allowed.`); + // Check for too many items of the same type + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); continue; } // Basic validation based on type @@ -801,83 +827,101 @@ jobs: console.log('No valid items found in agent output'); return; } - // Find the create-issue item - const createIssueItem = validatedOutput.items.find(/** @param {any} item */ item => item.type === 'create-issue'); - if (!createIssueItem) { - console.log('No create-issue item found in agent output'); + // Find all create-issue items + const createIssueItems = validatedOutput.items.filter(/** @param {any} item */ item => item.type === 'create-issue'); + if (createIssueItems.length === 0) { + console.log('No create-issue items found in agent output'); return; } - console.log('Found create-issue item:', { title: createIssueItem.title, bodyLength: createIssueItem.body.length }); + console.log(`Found ${createIssueItems.length} create-issue item(s)`); // Check if we're in an issue context (triggered by an issue event) const parentIssueNumber = context.payload?.issue?.number; - // Parse labels from environment variable (comma-separated string) or from the JSON item + // Parse labels from environment variable (comma-separated string) const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let labels = labelsEnv ? labelsEnv.split(',').map(/** @param {string} label */ label => label.trim()).filter(/** @param {string} label */ label => label) : []; - // If the item has labels, use those instead (or merge them) - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels].filter(Boolean); - } - // Extract title and body from the JSON item - let title = createIssueItem.title ? createIssueItem.title.trim() : ''; - let bodyLines = createIssueItem.body.split('\n'); - // If no title was found, use the body content as title (or a default) - if (!title) { - title = createIssueItem.body || 'Agent Output'; - } - // Apply title prefix if provided via environment variable - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (parentIssueNumber) { - console.log('Detected issue context, parent issue #' + parentIssueNumber); - // Add reference to parent issue in the child issue body - bodyLines.push(`Related to #${parentIssueNumber}`); - } - // Add AI disclaimer with run id, run htmlurl - // Add AI disclaimer with workflow run information - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> Generated by Agentic Workflow Run [${runId}](${runUrl})`, ''); - // Prepare the body content - const body = bodyLines.join('\n').trim(); - console.log('Creating issue with title:', title); - console.log('Labels:', labels); - console.log('Body length:', body.length); - // Create the issue using GitHub API - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels - }); - console.log('Created issue #' + issue.number + ': ' + issue.html_url); - // If we have a parent issue, add a comment to it referencing the new child issue - if (parentIssueNumber) { + let envLabels = labelsEnv ? labelsEnv.split(',').map(/** @param {string} label */ label => label.trim()).filter(/** @param {string} label */ label => label) : []; + const createdIssues = []; + // Process each create-issue item + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + console.log(`Processing create-issue item ${i + 1}/${createIssueItems.length}:`, { title: createIssueItem.title, bodyLength: createIssueItem.body.length }); + // Merge environment labels with item-specific labels + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels].filter(Boolean); + } + // Extract title and body from the JSON item + let title = createIssueItem.title ? createIssueItem.title.trim() : ''; + let bodyLines = createIssueItem.body.split('\n'); + // If no title was found, use the body content as title (or a default) + if (!title) { + title = createIssueItem.body || 'Agent Output'; + } + // Apply title prefix if provided via environment variable + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + if (parentIssueNumber) { + console.log('Detected issue context, parent issue #' + parentIssueNumber); + // Add reference to parent issue in the child issue body + bodyLines.push(`Related to #${parentIssueNumber}`); + } + // Add AI disclaimer with run id, run htmlurl + // Add AI disclaimer with workflow run information + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> Generated by Agentic Workflow Run [${runId}](${runUrl})`, ''); + // Prepare the body content + const body = bodyLines.join('\n').trim(); + console.log('Creating issue with title:', title); + console.log('Labels:', labels); + console.log('Body length:', body.length); try { - await github.rest.issues.createComment({ + // Create the issue using GitHub API + const { data: issue } = await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}` + title: title, + body: body, + labels: labels }); - console.log('Added comment to parent issue #' + parentIssueNumber); + console.log('Created issue #' + issue.number + ': ' + issue.html_url); + createdIssues.push(issue); + // If we have a parent issue, add a comment to it referencing the new child issue + if (parentIssueNumber) { + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}` + }); + console.log('Added comment to parent issue #' + parentIssueNumber); + } catch (error) { + console.log('Warning: Could not add comment to parent issue:', error instanceof Error ? error.message : String(error)); + } + } + // Set output for the last created issue (for backward compatibility) + if (i === createIssueItems.length - 1) { + core.setOutput('issue_number', issue.number); + core.setOutput('issue_url', issue.html_url); + } } catch (error) { - console.log('Warning: Could not add comment to parent issue:', error instanceof Error ? error.message : String(error)); + console.error(`✗ Failed to create issue "${title}":`, error instanceof Error ? error.message : String(error)); + throw error; + } + } + // Write summary for all created issues + if (createdIssues.length > 0) { + let summaryContent = '\n\n## GitHub Issues\n'; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; } + await core.summary.addRaw(summaryContent).write(); } - // Set output for other jobs to use - core.setOutput('issue_number', issue.number); - core.setOutput('issue_url', issue.html_url); - // write issue to summary - await core.summary.addRaw(` - ## GitHub Issue - - Issue ID: ${issue.number} - - Issue URL: ${issue.html_url} - `).write(); + console.log(`Successfully created ${createdIssues.length} issue(s)`); } await main(); diff --git a/.github/workflows/test-codex-create-pull-request.lock.yml b/.github/workflows/test-codex-create-pull-request.lock.yml index c48088188f4..70bea818799 100644 --- a/.github/workflows/test-codex-create-pull-request.lock.yml +++ b/.github/workflows/test-codex-create-pull-request.lock.yml @@ -204,113 +204,138 @@ jobs: GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"create-pull-request\":true}" with: script: | - /** - * Sanitizes content for safe output in GitHub Actions - * @param {string} content - The content to sanitize - * @returns {string} The sanitized content - */ - function sanitizeContent(content) { - if (!content || typeof content !== 'string') { - return ''; - } - // Read allowed domains from environment variable - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = [ - 'github.com', - 'github.io', - 'githubusercontent.com', - 'githubassets.com', - 'github.dev', - 'codespaces.new' - ]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - // Neutralize @mentions to prevent unintended notifications - sanitized = neutralizeMentions(sanitized); - // Remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); - // XML character escaping - sanitized = sanitized - .replace(/&/g, '&') // Must be first to avoid double-escaping - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - // URI filtering - replace non-https protocols with "(redacted)" - sanitized = sanitizeUrlProtocols(sanitized); - // Domain filtering for HTTPS URIs - sanitized = sanitizeUrlDomains(sanitized); - // Limit total length to prevent DoS (0.5MB max) - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; - } - // Limit number of lines to prevent log flooding (65k max) - const lines = sanitized.split('\n'); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; - } - // Remove ANSI escape sequences - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); - // Neutralize common bot trigger phrases - sanitized = neutralizeBotTriggers(sanitized); - // Trim excessive whitespace - return sanitized.trim(); + async function main() { + const fs = require("fs"); /** - * Remove unknown domains - * @param {string} s - The string to process - * @returns {string} The string with unknown domains redacted + * Sanitizes content for safe output in GitHub Actions + * @param {string} content - The content to sanitize + * @returns {string} The sanitized content */ - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { - // Extract the hostname part (before first slash, colon, or other delimiter) - const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); - // Check if this domain or any parent domain is in the allowlist - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + function sanitizeContent(content) { + if (!content || typeof content !== 'string') { + return ''; + } + // Read allowed domains from environment variable + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = [ + 'github.com', + 'github.io', + 'githubusercontent.com', + 'githubassets.com', + 'github.dev', + 'codespaces.new' + ]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + // Neutralize @mentions to prevent unintended notifications + sanitized = neutralizeMentions(sanitized); + // Remove control characters (except newlines and tabs) + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); + // XML character escaping + sanitized = sanitized + .replace(/&/g, '&') // Must be first to avoid double-escaping + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + // URI filtering - replace non-https protocols with "(redacted)" + sanitized = sanitizeUrlProtocols(sanitized); + // Domain filtering for HTTPS URIs + sanitized = sanitizeUrlDomains(sanitized); + // Limit total length to prevent DoS (0.5MB max) + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; + } + // Limit number of lines to prevent log flooding (65k max) + const lines = sanitized.split('\n'); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; + } + // Remove ANSI escape sequences + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); + // Neutralize common bot trigger phrases + sanitized = neutralizeBotTriggers(sanitized); + // Trim excessive whitespace + return sanitized.trim(); + /** + * Remove unknown domains + * @param {string} s - The string to process + * @returns {string} The string with unknown domains redacted + */ + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { + // Extract the hostname part (before first slash, colon, or other delimiter) + const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); + // Check if this domain or any parent domain is in the allowlist + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + }); + return isAllowed ? match : '(redacted)'; }); - return isAllowed ? match : '(redacted)'; - }); - } - /** - * Remove unknown protocols except https - * @param {string} s - The string to process - * @returns {string} The string with non-https protocols redacted - */ - function sanitizeUrlProtocols(s) { - // Match both protocol:// and protocol: patterns - return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - // Allow https (case insensitive), redact everything else - return protocol.toLowerCase() === 'https' ? match : '(redacted)'; - }); - } - /** - * Neutralizes @mentions by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized mentions - */ - function neutralizeMentions(s) { - // Replace @name or @org/team outside code with `@name` - return s.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}\``); + } + /** + * Remove unknown protocols except https + * @param {string} s - The string to process + * @returns {string} The string with non-https protocols redacted + */ + function sanitizeUrlProtocols(s) { + // Match both protocol:// and protocol: patterns + return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + // Allow https (case insensitive), redact everything else + return protocol.toLowerCase() === 'https' ? match : '(redacted)'; + }); + } + /** + * Neutralizes @mentions by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized mentions + */ + function neutralizeMentions(s) { + // Replace @name or @org/team outside code with `@name` + return s.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}\``); + } + /** + * Neutralizes bot trigger phrases by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized bot triggers + */ + function neutralizeBotTriggers(s) { + // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, + (match, action, ref) => `\`${action} #${ref}\``); + } } /** - * Neutralizes bot trigger phrases by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized bot triggers + * Gets the maximum allowed count for a given output type + * @param {string} itemType - The output item type + * @param {Object} config - The safe-outputs configuration + * @returns {number} The maximum allowed count */ - function neutralizeBotTriggers(s) { - // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, - (match, action, ref) => `\`${action} #${ref}\``); + function getMaxAllowedForType(itemType, config) { + // Check if max is explicitly specified in config + if (config && config[itemType] && typeof config[itemType] === 'object' && config[itemType].max) { + return config[itemType].max; + } + // Use default limits for plural-supported types + switch (itemType) { + case 'create-issue': + return 10; // Allow multiple issues + case 'add-issue-comment': + return 10; // Allow multiple comments + case 'create-pull-request': + return 1; // Only one pull request allowed + case 'add-issue-labels': + return 1; // Only one labels operation allowed + default: + return 1; // Default to single item for unknown types + } } - } - async function main() { - const fs = require("fs"); const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; if (!outputFile) { @@ -360,10 +385,11 @@ jobs: errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(', ')}`); continue; } - // Check for duplicates based on type (since current format allows only one of each) - const existingItem = parsedItems.find(existing => existing.type === itemType); - if (existingItem) { - errors.push(`Line ${i + 1}: Duplicate output type '${itemType}'. Only one item of each type is currently allowed.`); + // Check for too many items of the same type + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); continue; } // Basic validation based on type diff --git a/.github/workflows/test-codex-mcp.lock.yml b/.github/workflows/test-codex-mcp.lock.yml index b6ea3a408f1..68eaa1ce117 100644 --- a/.github/workflows/test-codex-mcp.lock.yml +++ b/.github/workflows/test-codex-mcp.lock.yml @@ -385,113 +385,138 @@ jobs: GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"create-issue\":true}" with: script: | - /** - * Sanitizes content for safe output in GitHub Actions - * @param {string} content - The content to sanitize - * @returns {string} The sanitized content - */ - function sanitizeContent(content) { - if (!content || typeof content !== 'string') { - return ''; - } - // Read allowed domains from environment variable - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = [ - 'github.com', - 'github.io', - 'githubusercontent.com', - 'githubassets.com', - 'github.dev', - 'codespaces.new' - ]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - // Neutralize @mentions to prevent unintended notifications - sanitized = neutralizeMentions(sanitized); - // Remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); - // XML character escaping - sanitized = sanitized - .replace(/&/g, '&') // Must be first to avoid double-escaping - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - // URI filtering - replace non-https protocols with "(redacted)" - sanitized = sanitizeUrlProtocols(sanitized); - // Domain filtering for HTTPS URIs - sanitized = sanitizeUrlDomains(sanitized); - // Limit total length to prevent DoS (0.5MB max) - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; - } - // Limit number of lines to prevent log flooding (65k max) - const lines = sanitized.split('\n'); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; - } - // Remove ANSI escape sequences - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); - // Neutralize common bot trigger phrases - sanitized = neutralizeBotTriggers(sanitized); - // Trim excessive whitespace - return sanitized.trim(); + async function main() { + const fs = require("fs"); /** - * Remove unknown domains - * @param {string} s - The string to process - * @returns {string} The string with unknown domains redacted + * Sanitizes content for safe output in GitHub Actions + * @param {string} content - The content to sanitize + * @returns {string} The sanitized content */ - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { - // Extract the hostname part (before first slash, colon, or other delimiter) - const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); - // Check if this domain or any parent domain is in the allowlist - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + function sanitizeContent(content) { + if (!content || typeof content !== 'string') { + return ''; + } + // Read allowed domains from environment variable + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = [ + 'github.com', + 'github.io', + 'githubusercontent.com', + 'githubassets.com', + 'github.dev', + 'codespaces.new' + ]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + // Neutralize @mentions to prevent unintended notifications + sanitized = neutralizeMentions(sanitized); + // Remove control characters (except newlines and tabs) + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); + // XML character escaping + sanitized = sanitized + .replace(/&/g, '&') // Must be first to avoid double-escaping + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + // URI filtering - replace non-https protocols with "(redacted)" + sanitized = sanitizeUrlProtocols(sanitized); + // Domain filtering for HTTPS URIs + sanitized = sanitizeUrlDomains(sanitized); + // Limit total length to prevent DoS (0.5MB max) + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; + } + // Limit number of lines to prevent log flooding (65k max) + const lines = sanitized.split('\n'); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; + } + // Remove ANSI escape sequences + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); + // Neutralize common bot trigger phrases + sanitized = neutralizeBotTriggers(sanitized); + // Trim excessive whitespace + return sanitized.trim(); + /** + * Remove unknown domains + * @param {string} s - The string to process + * @returns {string} The string with unknown domains redacted + */ + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { + // Extract the hostname part (before first slash, colon, or other delimiter) + const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); + // Check if this domain or any parent domain is in the allowlist + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + }); + return isAllowed ? match : '(redacted)'; }); - return isAllowed ? match : '(redacted)'; - }); - } - /** - * Remove unknown protocols except https - * @param {string} s - The string to process - * @returns {string} The string with non-https protocols redacted - */ - function sanitizeUrlProtocols(s) { - // Match both protocol:// and protocol: patterns - return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - // Allow https (case insensitive), redact everything else - return protocol.toLowerCase() === 'https' ? match : '(redacted)'; - }); - } - /** - * Neutralizes @mentions by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized mentions - */ - function neutralizeMentions(s) { - // Replace @name or @org/team outside code with `@name` - return s.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}\``); + } + /** + * Remove unknown protocols except https + * @param {string} s - The string to process + * @returns {string} The string with non-https protocols redacted + */ + function sanitizeUrlProtocols(s) { + // Match both protocol:// and protocol: patterns + return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + // Allow https (case insensitive), redact everything else + return protocol.toLowerCase() === 'https' ? match : '(redacted)'; + }); + } + /** + * Neutralizes @mentions by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized mentions + */ + function neutralizeMentions(s) { + // Replace @name or @org/team outside code with `@name` + return s.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}\``); + } + /** + * Neutralizes bot trigger phrases by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized bot triggers + */ + function neutralizeBotTriggers(s) { + // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, + (match, action, ref) => `\`${action} #${ref}\``); + } } /** - * Neutralizes bot trigger phrases by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized bot triggers + * Gets the maximum allowed count for a given output type + * @param {string} itemType - The output item type + * @param {Object} config - The safe-outputs configuration + * @returns {number} The maximum allowed count */ - function neutralizeBotTriggers(s) { - // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, - (match, action, ref) => `\`${action} #${ref}\``); + function getMaxAllowedForType(itemType, config) { + // Check if max is explicitly specified in config + if (config && config[itemType] && typeof config[itemType] === 'object' && config[itemType].max) { + return config[itemType].max; + } + // Use default limits for plural-supported types + switch (itemType) { + case 'create-issue': + return 10; // Allow multiple issues + case 'add-issue-comment': + return 10; // Allow multiple comments + case 'create-pull-request': + return 1; // Only one pull request allowed + case 'add-issue-labels': + return 1; // Only one labels operation allowed + default: + return 1; // Default to single item for unknown types + } } - } - async function main() { - const fs = require("fs"); const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; if (!outputFile) { @@ -541,10 +566,11 @@ jobs: errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(', ')}`); continue; } - // Check for duplicates based on type (since current format allows only one of each) - const existingItem = parsedItems.find(existing => existing.type === itemType); - if (existingItem) { - errors.push(`Line ${i + 1}: Duplicate output type '${itemType}'. Only one item of each type is currently allowed.`); + // Check for too many items of the same type + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); continue; } // Basic validation based on type @@ -989,83 +1015,101 @@ jobs: console.log('No valid items found in agent output'); return; } - // Find the create-issue item - const createIssueItem = validatedOutput.items.find(/** @param {any} item */ item => item.type === 'create-issue'); - if (!createIssueItem) { - console.log('No create-issue item found in agent output'); + // Find all create-issue items + const createIssueItems = validatedOutput.items.filter(/** @param {any} item */ item => item.type === 'create-issue'); + if (createIssueItems.length === 0) { + console.log('No create-issue items found in agent output'); return; } - console.log('Found create-issue item:', { title: createIssueItem.title, bodyLength: createIssueItem.body.length }); + console.log(`Found ${createIssueItems.length} create-issue item(s)`); // Check if we're in an issue context (triggered by an issue event) const parentIssueNumber = context.payload?.issue?.number; - // Parse labels from environment variable (comma-separated string) or from the JSON item + // Parse labels from environment variable (comma-separated string) const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let labels = labelsEnv ? labelsEnv.split(',').map(/** @param {string} label */ label => label.trim()).filter(/** @param {string} label */ label => label) : []; - // If the item has labels, use those instead (or merge them) - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels].filter(Boolean); - } - // Extract title and body from the JSON item - let title = createIssueItem.title ? createIssueItem.title.trim() : ''; - let bodyLines = createIssueItem.body.split('\n'); - // If no title was found, use the body content as title (or a default) - if (!title) { - title = createIssueItem.body || 'Agent Output'; - } - // Apply title prefix if provided via environment variable - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } - if (parentIssueNumber) { - console.log('Detected issue context, parent issue #' + parentIssueNumber); - // Add reference to parent issue in the child issue body - bodyLines.push(`Related to #${parentIssueNumber}`); - } - // Add AI disclaimer with run id, run htmlurl - // Add AI disclaimer with workflow run information - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> Generated by Agentic Workflow Run [${runId}](${runUrl})`, ''); - // Prepare the body content - const body = bodyLines.join('\n').trim(); - console.log('Creating issue with title:', title); - console.log('Labels:', labels); - console.log('Body length:', body.length); - // Create the issue using GitHub API - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels - }); - console.log('Created issue #' + issue.number + ': ' + issue.html_url); - // If we have a parent issue, add a comment to it referencing the new child issue - if (parentIssueNumber) { + let envLabels = labelsEnv ? labelsEnv.split(',').map(/** @param {string} label */ label => label.trim()).filter(/** @param {string} label */ label => label) : []; + const createdIssues = []; + // Process each create-issue item + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + console.log(`Processing create-issue item ${i + 1}/${createIssueItems.length}:`, { title: createIssueItem.title, bodyLength: createIssueItem.body.length }); + // Merge environment labels with item-specific labels + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels].filter(Boolean); + } + // Extract title and body from the JSON item + let title = createIssueItem.title ? createIssueItem.title.trim() : ''; + let bodyLines = createIssueItem.body.split('\n'); + // If no title was found, use the body content as title (or a default) + if (!title) { + title = createIssueItem.body || 'Agent Output'; + } + // Apply title prefix if provided via environment variable + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } + if (parentIssueNumber) { + console.log('Detected issue context, parent issue #' + parentIssueNumber); + // Add reference to parent issue in the child issue body + bodyLines.push(`Related to #${parentIssueNumber}`); + } + // Add AI disclaimer with run id, run htmlurl + // Add AI disclaimer with workflow run information + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> Generated by Agentic Workflow Run [${runId}](${runUrl})`, ''); + // Prepare the body content + const body = bodyLines.join('\n').trim(); + console.log('Creating issue with title:', title); + console.log('Labels:', labels); + console.log('Body length:', body.length); try { - await github.rest.issues.createComment({ + // Create the issue using GitHub API + const { data: issue } = await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}` + title: title, + body: body, + labels: labels }); - console.log('Added comment to parent issue #' + parentIssueNumber); + console.log('Created issue #' + issue.number + ': ' + issue.html_url); + createdIssues.push(issue); + // If we have a parent issue, add a comment to it referencing the new child issue + if (parentIssueNumber) { + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}` + }); + console.log('Added comment to parent issue #' + parentIssueNumber); + } catch (error) { + console.log('Warning: Could not add comment to parent issue:', error instanceof Error ? error.message : String(error)); + } + } + // Set output for the last created issue (for backward compatibility) + if (i === createIssueItems.length - 1) { + core.setOutput('issue_number', issue.number); + core.setOutput('issue_url', issue.html_url); + } } catch (error) { - console.log('Warning: Could not add comment to parent issue:', error instanceof Error ? error.message : String(error)); + console.error(`✗ Failed to create issue "${title}":`, error instanceof Error ? error.message : String(error)); + throw error; + } + } + // Write summary for all created issues + if (createdIssues.length > 0) { + let summaryContent = '\n\n## GitHub Issues\n'; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; } + await core.summary.addRaw(summaryContent).write(); } - // Set output for other jobs to use - core.setOutput('issue_number', issue.number); - core.setOutput('issue_url', issue.html_url); - // write issue to summary - await core.summary.addRaw(` - ## GitHub Issue - - Issue ID: ${issue.number} - - Issue URL: ${issue.html_url} - `).write(); + console.log(`Successfully created ${createdIssues.length} issue(s)`); } await main(); diff --git a/.github/workflows/test-proxy.lock.yml b/.github/workflows/test-proxy.lock.yml index e9c291dd2cc..24bb70fda7c 100644 --- a/.github/workflows/test-proxy.lock.yml +++ b/.github/workflows/test-proxy.lock.yml @@ -428,113 +428,138 @@ jobs: GITHUB_AW_SAFE_OUTPUTS_CONFIG: "{\"add-issue-comment\":true}" with: script: | - /** - * Sanitizes content for safe output in GitHub Actions - * @param {string} content - The content to sanitize - * @returns {string} The sanitized content - */ - function sanitizeContent(content) { - if (!content || typeof content !== 'string') { - return ''; - } - // Read allowed domains from environment variable - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = [ - 'github.com', - 'github.io', - 'githubusercontent.com', - 'githubassets.com', - 'github.dev', - 'codespaces.new' - ]; - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) - : defaultAllowedDomains; - let sanitized = content; - // Neutralize @mentions to prevent unintended notifications - sanitized = neutralizeMentions(sanitized); - // Remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); - // XML character escaping - sanitized = sanitized - .replace(/&/g, '&') // Must be first to avoid double-escaping - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - // URI filtering - replace non-https protocols with "(redacted)" - sanitized = sanitizeUrlProtocols(sanitized); - // Domain filtering for HTTPS URIs - sanitized = sanitizeUrlDomains(sanitized); - // Limit total length to prevent DoS (0.5MB max) - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; - } - // Limit number of lines to prevent log flooding (65k max) - const lines = sanitized.split('\n'); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; - } - // Remove ANSI escape sequences - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); - // Neutralize common bot trigger phrases - sanitized = neutralizeBotTriggers(sanitized); - // Trim excessive whitespace - return sanitized.trim(); + async function main() { + const fs = require("fs"); /** - * Remove unknown domains - * @param {string} s - The string to process - * @returns {string} The string with unknown domains redacted + * Sanitizes content for safe output in GitHub Actions + * @param {string} content - The content to sanitize + * @returns {string} The sanitized content */ - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { - // Extract the hostname part (before first slash, colon, or other delimiter) - const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); - // Check if this domain or any parent domain is in the allowlist - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + function sanitizeContent(content) { + if (!content || typeof content !== 'string') { + return ''; + } + // Read allowed domains from environment variable + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = [ + 'github.com', + 'github.io', + 'githubusercontent.com', + 'githubassets.com', + 'github.dev', + 'codespaces.new' + ]; + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) + : defaultAllowedDomains; + let sanitized = content; + // Neutralize @mentions to prevent unintended notifications + sanitized = neutralizeMentions(sanitized); + // Remove control characters (except newlines and tabs) + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); + // XML character escaping + sanitized = sanitized + .replace(/&/g, '&') // Must be first to avoid double-escaping + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + // URI filtering - replace non-https protocols with "(redacted)" + sanitized = sanitizeUrlProtocols(sanitized); + // Domain filtering for HTTPS URIs + sanitized = sanitizeUrlDomains(sanitized); + // Limit total length to prevent DoS (0.5MB max) + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; + } + // Limit number of lines to prevent log flooding (65k max) + const lines = sanitized.split('\n'); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; + } + // Remove ANSI escape sequences + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); + // Neutralize common bot trigger phrases + sanitized = neutralizeBotTriggers(sanitized); + // Trim excessive whitespace + return sanitized.trim(); + /** + * Remove unknown domains + * @param {string} s - The string to process + * @returns {string} The string with unknown domains redacted + */ + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { + // Extract the hostname part (before first slash, colon, or other delimiter) + const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); + // Check if this domain or any parent domain is in the allowlist + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + }); + return isAllowed ? match : '(redacted)'; }); - return isAllowed ? match : '(redacted)'; - }); - } - /** - * Remove unknown protocols except https - * @param {string} s - The string to process - * @returns {string} The string with non-https protocols redacted - */ - function sanitizeUrlProtocols(s) { - // Match both protocol:// and protocol: patterns - return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - // Allow https (case insensitive), redact everything else - return protocol.toLowerCase() === 'https' ? match : '(redacted)'; - }); - } - /** - * Neutralizes @mentions by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized mentions - */ - function neutralizeMentions(s) { - // Replace @name or @org/team outside code with `@name` - return s.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}\``); + } + /** + * Remove unknown protocols except https + * @param {string} s - The string to process + * @returns {string} The string with non-https protocols redacted + */ + function sanitizeUrlProtocols(s) { + // Match both protocol:// and protocol: patterns + return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + // Allow https (case insensitive), redact everything else + return protocol.toLowerCase() === 'https' ? match : '(redacted)'; + }); + } + /** + * Neutralizes @mentions by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized mentions + */ + function neutralizeMentions(s) { + // Replace @name or @org/team outside code with `@name` + return s.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}\``); + } + /** + * Neutralizes bot trigger phrases by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized bot triggers + */ + function neutralizeBotTriggers(s) { + // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, + (match, action, ref) => `\`${action} #${ref}\``); + } } /** - * Neutralizes bot trigger phrases by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized bot triggers + * Gets the maximum allowed count for a given output type + * @param {string} itemType - The output item type + * @param {Object} config - The safe-outputs configuration + * @returns {number} The maximum allowed count */ - function neutralizeBotTriggers(s) { - // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, - (match, action, ref) => `\`${action} #${ref}\``); + function getMaxAllowedForType(itemType, config) { + // Check if max is explicitly specified in config + if (config && config[itemType] && typeof config[itemType] === 'object' && config[itemType].max) { + return config[itemType].max; + } + // Use default limits for plural-supported types + switch (itemType) { + case 'create-issue': + return 10; // Allow multiple issues + case 'add-issue-comment': + return 10; // Allow multiple comments + case 'create-pull-request': + return 1; // Only one pull request allowed + case 'add-issue-labels': + return 1; // Only one labels operation allowed + default: + return 1; // Default to single item for unknown types + } } - } - async function main() { - const fs = require("fs"); const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; if (!outputFile) { @@ -584,10 +609,11 @@ jobs: errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(', ')}`); continue; } - // Check for duplicates based on type (since current format allows only one of each) - const existingItem = parsedItems.find(existing => existing.type === itemType); - if (existingItem) { - errors.push(`Line ${i + 1}: Duplicate output type '${itemType}'. Only one item of each type is currently allowed.`); + // Check for too many items of the same type + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); continue; } // Basic validation based on type @@ -1089,13 +1115,13 @@ jobs: console.log('No valid items found in agent output'); return; } - // Find the add-issue-comment item - const commentItem = validatedOutput.items.find(/** @param {any} item */ item => item.type === 'add-issue-comment'); - if (!commentItem) { - console.log('No add-issue-comment item found in agent output'); + // Find all add-issue-comment items + const commentItems = validatedOutput.items.filter(/** @param {any} item */ item => item.type === 'add-issue-comment'); + if (commentItems.length === 0) { + console.log('No add-issue-comment items found in agent output'); return; } - console.log('Found add-issue-comment item:', { bodyLength: commentItem.body.length }); + console.log(`Found ${commentItems.length} add-issue-comment item(s)`); // Check if we're in an issue or pull request context 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'; @@ -1103,57 +1129,75 @@ jobs: console.log('Not running in issue or pull request context, skipping comment creation'); return; } - // Determine the issue/PR number and comment endpoint - let issueNumber; - let commentEndpoint; - if (isIssueContext) { - if (context.payload.issue) { - issueNumber = context.payload.issue.number; - commentEndpoint = 'issues'; - } else { - console.log('Issue context detected but no issue found in payload'); - return; + const createdComments = []; + // Process each comment item + for (let i = 0; i < commentItems.length; i++) { + const commentItem = commentItems[i]; + console.log(`Processing add-issue-comment item ${i + 1}/${commentItems.length}:`, { bodyLength: commentItem.body.length }); + // Determine the issue/PR number and comment endpoint for this comment + let issueNumber; + let commentEndpoint; + if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; + commentEndpoint = 'issues'; + } else { + console.log('Issue context detected but no issue found in payload'); + continue; + } + } else if (isPRContext) { + if (context.payload.pull_request) { + issueNumber = context.payload.pull_request.number; + commentEndpoint = 'issues'; // PR comments use the issues API endpoint + } else { + console.log('Pull request context detected but no pull request found in payload'); + continue; + } } - } else if (isPRContext) { - if (context.payload.pull_request) { - issueNumber = context.payload.pull_request.number; - commentEndpoint = 'issues'; // PR comments use the issues API endpoint - } else { - console.log('Pull request context detected but no pull request found in payload'); - return; + if (!issueNumber) { + console.log('Could not determine issue or pull request number'); + continue; + } + // Extract body from the JSON item + let body = commentItem.body.trim(); + // Add AI disclaimer with run id, run htmlurl + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + body += `\n\n> Generated by Agentic Workflow Run [${runId}](${runUrl})\n`; + console.log(`Creating comment on ${commentEndpoint} #${issueNumber}`); + console.log('Comment content length:', body.length); + try { + // Create the comment using GitHub API + const { data: comment } = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: body + }); + console.log('Created comment #' + comment.id + ': ' + comment.html_url); + createdComments.push(comment); + // Set output for the last created comment (for backward compatibility) + if (i === commentItems.length - 1) { + core.setOutput('comment_id', comment.id); + core.setOutput('comment_url', comment.html_url); + } + } catch (error) { + console.error(`✗ Failed to create comment:`, error instanceof Error ? error.message : String(error)); + throw error; } } - if (!issueNumber) { - console.log('Could not determine issue or pull request number'); - return; + // Write summary for all created comments + if (createdComments.length > 0) { + let summaryContent = '\n\n## GitHub Comments\n'; + for (const comment of createdComments) { + summaryContent += `- Comment #${comment.id}: [View Comment](${comment.html_url})\n`; + } + await core.summary.addRaw(summaryContent).write(); } - // Extract body from the JSON item - let body = commentItem.body.trim(); - // Add AI disclaimer with run id, run htmlurl - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - body += `\n\n> Generated by Agentic Workflow Run [${runId}](${runUrl})\n`; - console.log(`Creating comment on ${commentEndpoint} #${issueNumber}`); - console.log('Comment content length:', body.length); - // Create the comment using GitHub API - const { data: comment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body: body - }); - console.log('Created comment #' + comment.id + ': ' + comment.html_url); - // Set output for other jobs to use - core.setOutput('comment_id', comment.id); - core.setOutput('comment_url', comment.html_url); - // write comment id, url to the github_step_summary - await core.summary.addRaw(` - ## GitHub Comment - - Comment ID: ${comment.id} - - Comment URL: ${comment.html_url} - `).write(); + console.log(`Successfully created ${createdComments.length} comment(s)`); + return createdComments; } await main(); diff --git a/docs/safe-outputs.md b/docs/safe-outputs.md index 0b07134a9b1..1a44242ec1e 100644 --- a/docs/safe-outputs.md +++ b/docs/safe-outputs.md @@ -13,47 +13,62 @@ The `safe-outputs:` element of your workflow's frontmatter declares that your ag ## Available Output Types -### New Issue Creation (`create-issue:`) +### New Issue Creation (`create-issue:` / `create-issues:`) -Adding `create-issue:` to the `safe-outputs:` section of your workflow declares that the workflow should conclude with the creation of a GitHub issue based on the workflow's output. +Adding issue creation to the `safe-outputs:` section declares that the workflow should conclude with the creation of GitHub issues based on the workflow's output. +**Singular Form (create exactly one issue):** ```yaml safe-outputs: create-issue: ``` -or with further configuration: +**Plural Form (create multiple issues):** +```yaml +safe-outputs: + create-issues: + max: 5 # Optional: maximum number of issues (default: 10) +``` +**With Configuration:** ```yaml safe-outputs: - create-issue: - title-prefix: "[ai] " # Optional: prefix for issue titles - labels: [automation, agent] # Optional: labels to attach to issues + create-issue: # Singular form + title-prefix: "[ai] " # Optional: prefix for issue titles + labels: [automation, agent] # Optional: labels to attach to issues ``` -The agentic part of your workflow should describe the issue it wants created. +The agentic part of your workflow should describe the issue(s) it wants created. -**Example natural language to generate the output:** +**Example markdown to generate the output:** ```yaml # Code Analysis Agent Analyze the latest commit and provide insights. -Create a new issue with your findings with title "AI Code Analysis" and description "Here are the details of the analysis..." +Create new issues with your findings. For each issue, provide a title starting with "AI Code Analysis" and detailed description of the analysis findings. ``` -The workflow will have additional prompting describing that, to create the issue, the agent should write the issue title and body to a file. +The workflow will have additional prompting describing that, to create issues, the agent should write the issue details to a file. -### Issue Comment Creation (`add-issue-comment:`) +### Issue Comment Creation (`add-issue-comment:` / `add-issue-comments:`) -Adding `add-issue-comment:` to the `safe-outputs:` section of your workflow declares that the workflow should conclude with posting a comment on the triggering issue or pull request based on the workflow's output. +Adding comment creation to the `safe-outputs:` section declares that the workflow should conclude with posting comments on the triggering issue or pull request based on the workflow's output. +**Singular Form (adds exactly one comment):** ```yaml safe-outputs: add-issue-comment: ``` -The agentic part of your workflow should describe the comment it wants posted. +**Plural Form (adds multiple comments):** +```yaml +safe-outputs: + add-issue-comments: + max: 3 # Optional: maximum number of comments (default: 10) +``` + +The agentic part of your workflow should describe the comment(s) it wants posted. **Example natural language to generate the output:** @@ -61,33 +76,34 @@ The agentic part of your workflow should describe the comment it wants posted. # Issue/PR Analysis Agent Analyze the issue or pull request and provide feedback. -Create an issue comment on the triggering issue or PR starting with the text "Here is my analysis of the issue/PR..." +Create issue comments on the triggering issue or PR with your analysis findings. Each comment should provide specific insights about different aspects of the issue. ``` -The workflow will have additional prompting describing that, to create the issue, the agent should write the comment body to a special file.. +The workflow will have additional prompting describing that, to create comments, the agent should write the comment content to a special file. ### Pull Request Creation (`create-pull-request:`) -Adding `create-pull-request:` to the `safe-outputs:` section of your workflow declares that the workflow should conclude with the creation of a pull request containing code changes generated by the workflow. +Adding pull request creation to the `safe-outputs:` section declares that the workflow should conclude with the creation of a pull request containing code changes generated by the workflow. ```yaml safe-outputs: create-pull-request: ``` -or with further configuration: - +**With Configuration:** ```yaml safe-outputs: - create-pull-request: - title-prefix: "[ai] " # Optional: prefix for PR titles - labels: [automation, ai-agent] # Optional: labels to attach to PRs - draft: true # Optional: create as draft PR (defaults to true) + create-pull-request: # Creates exactly one pull request + title-prefix: "[ai] " # Optional: prefix for PR titles + labels: [automation, ai-agent] # Optional: labels to attach to PRs + draft: true # Optional: create as draft PR (defaults to true) ``` -The agentic part of your workflow should instruct to +At most one pull request is currently supported. + +The agentic part of your workflow should instruct to: 1. **Make code changes**: Make any code changes in the working directory—these are automatically collected using `git add -A` and committed -2. **Create a pull request**: Describe the pull request title and body you want +2. **Create pull request**: Describe the pull request title and body content you want **Example natural language to generate the output:** @@ -97,7 +113,7 @@ The agentic part of your workflow should instruct to Analyze the latest commit and suggest improvements. 1. Make any file changes directly in the working directory -2. Create a PR with title "First change" and description "THis is a first change" +2. Create a pull request for your improvements, with a descriptive title and detailed description of the changes made ``` ### Label Addition (`add-issue-labels:`) diff --git a/pkg/cli/commands.go b/pkg/cli/commands.go index 0f1ddf7e9fe..91a8251a1b0 100644 --- a/pkg/cli/commands.go +++ b/pkg/cli/commands.go @@ -3685,9 +3685,13 @@ permissions: # Outputs - what APIs and tools can the AI use? safe-outputs: - create-issue: - # create-pull-request: - # add-issue-comment: + create-issue: # Creates exactly one issue + # create-issues: # Creates multiple issues (default max: 10) + # max: 5 # Optional: specify maximum number + # create-pull-request: # Creates exactly one pull request + # add-issue-comment: # Adds exactly one comment + # add-issue-comments: # Adds multiple comments (default max: 10) + # max: 2 # Optional: specify maximum number # add-issue-labels: --- diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 10e47efc018..3298e95c05c 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1009,6 +1009,38 @@ } ] }, + "create-issues": { + "oneOf": [ + { + "type": "object", + "description": "Configuration for creating GitHub issues from agentic workflow output (plural form)", + "properties": { + "title-prefix": { + "type": "string", + "description": "Optional prefix for the issue title" + }, + "labels": { + "type": "array", + "description": "Optional list of labels to attach to the issue", + "items": { + "type": "string" + } + }, + "max": { + "type": "integer", + "description": "Maximum number of issues to create (default: 10)", + "minimum": 1, + "maximum": 100 + } + }, + "additionalProperties": false + }, + { + "type": "null", + "description": "Enable issue creation with default configuration (max: 10)" + } + ] + }, "add-issue-comment": { "oneOf": [ { @@ -1022,6 +1054,27 @@ } ] }, + "add-issue-comments": { + "oneOf": [ + { + "type": "object", + "description": "Configuration for creating GitHub issue/PR comments from agentic workflow output (plural form)", + "properties": { + "max": { + "type": "integer", + "description": "Maximum number of comments to create (default: 10)", + "minimum": 1, + "maximum": 100 + } + }, + "additionalProperties": false + }, + { + "type": "null", + "description": "Enable issue comment creation with default configuration (max: 10)" + } + ] + }, "create-pull-request": { "oneOf": [ { diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index fa00d31ec01..59242753043 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -142,29 +142,36 @@ type WorkflowData struct { // SafeOutputsConfig holds configuration for automatic output routes type SafeOutputsConfig struct { - CreateIssue *CreateIssueConfig `yaml:"create-issue,omitempty"` - AddIssueComment *AddIssueCommentConfig `yaml:"add-issue-comment,omitempty"` - CreatePullRequest *CreatePullRequestConfig `yaml:"create-pull-request,omitempty"` - AddIssueLabels *AddIssueLabelsConfig `yaml:"add-issue-labels,omitempty"` - AllowedDomains []string `yaml:"allowed-domains,omitempty"` + CreateIssues *CreateIssuesConfig `yaml:"create-issues,omitempty"` + AddIssueComments *AddIssueCommentsConfig `yaml:"add-issue-comments,omitempty"` + CreatePullRequests *CreatePullRequestsConfig `yaml:"create-pull-requests,omitempty"` + AddIssueLabels *AddIssueLabelsConfig `yaml:"add-issue-labels,omitempty"` + AllowedDomains []string `yaml:"allowed-domains,omitempty"` } -// CreateIssueConfig holds configuration for creating GitHub issues from agent output -type CreateIssueConfig struct { +// CreateIssuesConfig holds configuration for creating GitHub issues from agent output +type CreateIssuesConfig struct { TitlePrefix string `yaml:"title-prefix,omitempty"` Labels []string `yaml:"labels,omitempty"` + Max int `yaml:"max,omitempty"` // Maximum number of issues to create } -// AddIssueCommentConfig holds configuration for creating GitHub issue/PR comments from agent output +// AddIssueCommentConfig holds configuration for creating GitHub issue/PR comments from agent output (deprecated, use AddIssueCommentsConfig) type AddIssueCommentConfig struct { // Empty struct for now, as per requirements, but structured for future expansion } -// CreatePullRequestConfig holds configuration for creating GitHub pull requests from agent output -type CreatePullRequestConfig struct { +// AddIssueCommentsConfig holds configuration for creating GitHub issue/PR comments from agent output +type AddIssueCommentsConfig struct { + Max int `yaml:"max,omitempty"` // Maximum number of comments to create +} + +// CreatePullRequestsConfig holds configuration for creating GitHub pull requests from agent output +type CreatePullRequestsConfig struct { TitlePrefix string `yaml:"title-prefix,omitempty"` Labels []string `yaml:"labels,omitempty"` Draft *bool `yaml:"draft,omitempty"` // Pointer to distinguish between unset (nil) and explicitly false + Max int `yaml:"max,omitempty"` // Maximum number of pull requests to create } // AddIssueLabelsConfig holds configuration for adding labels to issues/PRs from agent output @@ -1542,7 +1549,7 @@ func (c *Compiler) buildJobs(data *WorkflowData) error { if data.SafeOutputs != nil { // Build create_issue job if output.create_issue is configured - if data.SafeOutputs.CreateIssue != nil { + if data.SafeOutputs.CreateIssues != nil { createIssueJob, err := c.buildCreateOutputIssueJob(data, jobName) if err != nil { return fmt.Errorf("failed to build create_issue job: %w", err) @@ -1552,8 +1559,8 @@ func (c *Compiler) buildJobs(data *WorkflowData) error { } } - // Build create_issue_comment job if output.add-issue-comment is configured - if data.SafeOutputs.AddIssueComment != nil { + // Build create_issue_comment job if output.add-issue-comments is configured + if data.SafeOutputs.AddIssueComments != nil { createCommentJob, err := c.buildCreateOutputAddIssueCommentJob(data, jobName) if err != nil { return fmt.Errorf("failed to build create_issue_comment job: %w", err) @@ -1563,8 +1570,8 @@ func (c *Compiler) buildJobs(data *WorkflowData) error { } } - // Build create_pull_request job if output.create-pull-request is configured - if data.SafeOutputs.CreatePullRequest != nil { + // Build create_pull_request job if output.create-pull-requests is configured + if data.SafeOutputs.CreatePullRequests != nil { createPullRequestJob, err := c.buildCreateOutputPullRequestJob(data, jobName) if err != nil { return fmt.Errorf("failed to build create_pull_request job: %w", err) @@ -1716,8 +1723,8 @@ func (c *Compiler) buildAddReactionJob(data *WorkflowData, taskJobCreated bool) // buildCreateOutputIssueJob creates the create_issue job func (c *Compiler) buildCreateOutputIssueJob(data *WorkflowData, mainJobName string) (*Job, error) { - if data.SafeOutputs == nil || data.SafeOutputs.CreateIssue == nil { - return nil, fmt.Errorf("safe-outputs.create-issue configuration is required") + if data.SafeOutputs == nil || data.SafeOutputs.CreateIssues == nil { + return nil, fmt.Errorf("safe-outputs.create-issues configuration is required") } var steps []string @@ -1729,11 +1736,11 @@ func (c *Compiler) buildCreateOutputIssueJob(data *WorkflowData, mainJobName str steps = append(steps, " env:\n") // Pass the agent output content from the main job steps = append(steps, fmt.Sprintf(" GITHUB_AW_AGENT_OUTPUT: ${{ needs.%s.outputs.output }}\n", mainJobName)) - if data.SafeOutputs.CreateIssue.TitlePrefix != "" { - steps = append(steps, fmt.Sprintf(" GITHUB_AW_ISSUE_TITLE_PREFIX: %q\n", data.SafeOutputs.CreateIssue.TitlePrefix)) + if data.SafeOutputs.CreateIssues.TitlePrefix != "" { + steps = append(steps, fmt.Sprintf(" GITHUB_AW_ISSUE_TITLE_PREFIX: %q\n", data.SafeOutputs.CreateIssues.TitlePrefix)) } - if len(data.SafeOutputs.CreateIssue.Labels) > 0 { - labelsStr := strings.Join(data.SafeOutputs.CreateIssue.Labels, ",") + if len(data.SafeOutputs.CreateIssues.Labels) > 0 { + labelsStr := strings.Join(data.SafeOutputs.CreateIssues.Labels, ",") steps = append(steps, fmt.Sprintf(" GITHUB_AW_ISSUE_LABELS: %q\n", labelsStr)) } @@ -1766,8 +1773,8 @@ func (c *Compiler) buildCreateOutputIssueJob(data *WorkflowData, mainJobName str // buildCreateOutputAddIssueCommentJob creates the create_issue_comment job func (c *Compiler) buildCreateOutputAddIssueCommentJob(data *WorkflowData, mainJobName string) (*Job, error) { - if data.SafeOutputs == nil || data.SafeOutputs.AddIssueComment == nil { - return nil, fmt.Errorf("safe-outputs.add-issue-comment configuration is required") + if data.SafeOutputs == nil || data.SafeOutputs.AddIssueComments == nil { + return nil, fmt.Errorf("safe-outputs.add-issue-comments configuration is required") } var steps []string @@ -1809,8 +1816,8 @@ func (c *Compiler) buildCreateOutputAddIssueCommentJob(data *WorkflowData, mainJ // buildCreateOutputPullRequestJob creates the create_pull_request job func (c *Compiler) buildCreateOutputPullRequestJob(data *WorkflowData, mainJobName string) (*Job, error) { - if data.SafeOutputs == nil || data.SafeOutputs.CreatePullRequest == nil { - return nil, fmt.Errorf("safe-outputs.pull-request configuration is required") + if data.SafeOutputs == nil || data.SafeOutputs.CreatePullRequests == nil { + return nil, fmt.Errorf("safe-outputs.create-pull-requests configuration is required") } var steps []string @@ -1841,17 +1848,17 @@ func (c *Compiler) buildCreateOutputPullRequestJob(data *WorkflowData, mainJobNa steps = append(steps, fmt.Sprintf(" GITHUB_AW_WORKFLOW_ID: %q\n", mainJobName)) // Pass the base branch from GitHub context steps = append(steps, " GITHUB_AW_BASE_BRANCH: ${{ github.ref_name }}\n") - if data.SafeOutputs.CreatePullRequest.TitlePrefix != "" { - steps = append(steps, fmt.Sprintf(" GITHUB_AW_PR_TITLE_PREFIX: %q\n", data.SafeOutputs.CreatePullRequest.TitlePrefix)) + if data.SafeOutputs.CreatePullRequests.TitlePrefix != "" { + steps = append(steps, fmt.Sprintf(" GITHUB_AW_PR_TITLE_PREFIX: %q\n", data.SafeOutputs.CreatePullRequests.TitlePrefix)) } - if len(data.SafeOutputs.CreatePullRequest.Labels) > 0 { - labelsStr := strings.Join(data.SafeOutputs.CreatePullRequest.Labels, ",") + if len(data.SafeOutputs.CreatePullRequests.Labels) > 0 { + labelsStr := strings.Join(data.SafeOutputs.CreatePullRequests.Labels, ",") steps = append(steps, fmt.Sprintf(" GITHUB_AW_PR_LABELS: %q\n", labelsStr)) } // Pass draft setting - default to true for backwards compatibility draftValue := true // Default value - if data.SafeOutputs.CreatePullRequest.Draft != nil { - draftValue = *data.SafeOutputs.CreatePullRequest.Draft + if data.SafeOutputs.CreatePullRequests.Draft != nil { + draftValue = *data.SafeOutputs.CreatePullRequests.Draft } steps = append(steps, fmt.Sprintf(" GITHUB_AW_PR_DRAFT: %q\n", fmt.Sprintf("%t", draftValue))) @@ -2265,17 +2272,17 @@ func (c *Compiler) generatePrompt(yaml *strings.Builder, data *WorkflowData, eng yaml.WriteString(" \n") yaml.WriteString(" ## ") written := false - if data.SafeOutputs.AddIssueComment != nil { + if data.SafeOutputs.AddIssueComments != nil { yaml.WriteString("Adding a Comment to an Issue or Pull Request") written = true } - if data.SafeOutputs.CreateIssue != nil { + if data.SafeOutputs.CreateIssues != nil { if written { yaml.WriteString(", ") } yaml.WriteString("Creating an Issue") } - if data.SafeOutputs.CreatePullRequest != nil { + if data.SafeOutputs.CreatePullRequests != nil { if written { yaml.WriteString(", ") } @@ -2297,7 +2304,7 @@ func (c *Compiler) generatePrompt(yaml *strings.Builder, data *WorkflowData, eng yaml.WriteString(" ### Available Output Types:\n") yaml.WriteString(" \n") - if data.SafeOutputs.AddIssueComment != nil { + if data.SafeOutputs.AddIssueComments != nil { yaml.WriteString(" **Adding a Comment to an Issue or Pull Request**\n") yaml.WriteString(" ```json\n") yaml.WriteString(" {\"type\": \"add-issue-comment\", \"body\": \"Your comment content in markdown\"}\n") @@ -2305,7 +2312,7 @@ func (c *Compiler) generatePrompt(yaml *strings.Builder, data *WorkflowData, eng yaml.WriteString(" \n") } - if data.SafeOutputs.CreateIssue != nil { + if data.SafeOutputs.CreateIssues != nil { yaml.WriteString(" **Creating an Issue**\n") yaml.WriteString(" ```json\n") yaml.WriteString(" {\"type\": \"create-issue\", \"title\": \"Issue title\", \"body\": \"Issue body in markdown\", \"labels\": [\"optional\", \"labels\"]}\n") @@ -2313,7 +2320,7 @@ func (c *Compiler) generatePrompt(yaml *strings.Builder, data *WorkflowData, eng yaml.WriteString(" \n") } - if data.SafeOutputs.CreatePullRequest != nil { + if data.SafeOutputs.CreatePullRequests != nil { yaml.WriteString(" **Creating a Pull Request**\n") yaml.WriteString(" \n") yaml.WriteString(" To create a pull request:\n") @@ -2339,15 +2346,15 @@ func (c *Compiler) generatePrompt(yaml *strings.Builder, data *WorkflowData, eng // Generate conditional examples based on enabled SafeOutputs exampleCount := 0 - if data.SafeOutputs.CreateIssue != nil { + if data.SafeOutputs.CreateIssues != nil { yaml.WriteString(" {\"type\": \"create-issue\", \"title\": \"Bug Report\", \"body\": \"Found an issue with...\"}\n") exampleCount++ } - if data.SafeOutputs.AddIssueComment != nil { + if data.SafeOutputs.AddIssueComments != nil { yaml.WriteString(" {\"type\": \"add-issue-comment\", \"body\": \"This is related to the issue above.\"}\n") exampleCount++ } - if data.SafeOutputs.CreatePullRequest != nil { + if data.SafeOutputs.CreatePullRequests != nil { yaml.WriteString(" {\"type\": \"create-pull-request\", \"title\": \"Fix typo\", \"body\": \"Corrected spelling mistake in documentation\"}\n") exampleCount++ } @@ -2417,86 +2424,22 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut if outputMap, ok := output.(map[string]any); ok { config := &SafeOutputsConfig{} - // Parse create-issue configuration - if issue, exists := outputMap["create-issue"]; exists { - if issueMap, ok := issue.(map[string]any); ok { - issueConfig := &CreateIssueConfig{} - - // Parse title-prefix - if titlePrefix, exists := issueMap["title-prefix"]; exists { - if titlePrefixStr, ok := titlePrefix.(string); ok { - issueConfig.TitlePrefix = titlePrefixStr - } - } - - // Parse labels - if labels, exists := issueMap["labels"]; exists { - if labelsArray, ok := labels.([]any); ok { - var labelStrings []string - for _, label := range labelsArray { - if labelStr, ok := label.(string); ok { - labelStrings = append(labelStrings, labelStr) - } - } - issueConfig.Labels = labelStrings - } - } - - config.CreateIssue = issueConfig - } else if issue == nil { - // Handle null case: create empty config - config.CreateIssue = &CreateIssueConfig{} - } + // Handle create-issue and create-issues + issuesConfig := c.parseIssuesConfig(outputMap) + if issuesConfig != nil { + config.CreateIssues = issuesConfig } - // Parse add-issue-comment configuration - if comment, exists := outputMap["add-issue-comment"]; exists { - if _, ok := comment.(map[string]any); ok { - // For now, CommentConfig is an empty struct - config.AddIssueComment = &AddIssueCommentConfig{} - } else if comment == nil { - // Handle null case: create empty config - config.AddIssueComment = &AddIssueCommentConfig{} - } + // Handle add-issue-comment and add-issue-comments + commentsConfig := c.parseCommentsConfig(outputMap) + if commentsConfig != nil { + config.AddIssueComments = commentsConfig } - // Parse create-pull-request configuration - if pullRequest, exists := outputMap["create-pull-request"]; exists { - if pullRequestMap, ok := pullRequest.(map[string]any); ok { - pullRequestConfig := &CreatePullRequestConfig{} - - // Parse title-prefix - if titlePrefix, exists := pullRequestMap["title-prefix"]; exists { - if titlePrefixStr, ok := titlePrefix.(string); ok { - pullRequestConfig.TitlePrefix = titlePrefixStr - } - } - - // Parse labels - if labels, exists := pullRequestMap["labels"]; exists { - if labelsArray, ok := labels.([]any); ok { - var labelStrings []string - for _, label := range labelsArray { - if labelStr, ok := label.(string); ok { - labelStrings = append(labelStrings, labelStr) - } - } - pullRequestConfig.Labels = labelStrings - } - } - - // Parse draft - if draft, exists := pullRequestMap["draft"]; exists { - if draftBool, ok := draft.(bool); ok { - pullRequestConfig.Draft = &draftBool - } - } - - config.CreatePullRequest = pullRequestConfig - } else if pullRequest == nil { - // Handle null case: create empty config - config.CreatePullRequest = &CreatePullRequestConfig{} - } + // Handle create-pull-request and create-pull-requests + pullRequestsConfig := c.parsePullRequestsConfig(outputMap) + if pullRequestsConfig != nil { + config.CreatePullRequests = pullRequestsConfig } // Parse allowed-domains configuration @@ -2567,6 +2510,196 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut return nil } +// parseIssuesConfig handles both create-issue (singular) and create-issues (plural) configurations +func (c *Compiler) parseIssuesConfig(outputMap map[string]any) *CreateIssuesConfig { + // Check for both singular and plural forms + hasSingular := false + hasPlural := false + + if _, exists := outputMap["create-issue"]; exists { + hasSingular = true + } + if _, exists := outputMap["create-issues"]; exists { + hasPlural = true + } + + // Error if both are specified + if hasSingular && hasPlural { + // This should be caught by validation, but we'll handle it gracefully + // Prefer plural form + hasSingular = false + } + + var configData any + var defaultMax int + + if hasPlural { + configData = outputMap["create-issues"] + defaultMax = 10 // Default for plural form + } else if hasSingular { + configData = outputMap["create-issue"] + defaultMax = 1 // Singular form always has max 1 + } else { + return nil + } + + issuesConfig := &CreateIssuesConfig{Max: defaultMax} + + if configMap, ok := configData.(map[string]any); ok { + // Parse title-prefix + if titlePrefix, exists := configMap["title-prefix"]; exists { + if titlePrefixStr, ok := titlePrefix.(string); ok { + issuesConfig.TitlePrefix = titlePrefixStr + } + } + + // Parse labels + if labels, exists := configMap["labels"]; exists { + if labelsArray, ok := labels.([]any); ok { + var labelStrings []string + for _, label := range labelsArray { + if labelStr, ok := label.(string); ok { + labelStrings = append(labelStrings, labelStr) + } + } + issuesConfig.Labels = labelStrings + } + } + + // Parse max (only for plural form) + if hasPlural { + if max, exists := configMap["max"]; exists { + if maxInt, ok := c.parseIntValue(max); ok { + issuesConfig.Max = maxInt + } + } + } + } + + return issuesConfig +} + +// parseCommentsConfig handles both add-issue-comment (singular) and add-issue-comments (plural) configurations +func (c *Compiler) parseCommentsConfig(outputMap map[string]any) *AddIssueCommentsConfig { + // Check for both singular and plural forms + hasSingular := false + hasPlural := false + + if _, exists := outputMap["add-issue-comment"]; exists { + hasSingular = true + } + if _, exists := outputMap["add-issue-comments"]; exists { + hasPlural = true + } + + // Error if both are specified + if hasSingular && hasPlural { + // This should be caught by validation, but we'll handle it gracefully + // Prefer plural form + hasSingular = false + } + + var configData any + var defaultMax int + + if hasPlural { + configData = outputMap["add-issue-comments"] + defaultMax = 10 // Default for plural form + } else if hasSingular { + configData = outputMap["add-issue-comment"] + defaultMax = 1 // Singular form always has max 1 + } else { + return nil + } + + commentsConfig := &AddIssueCommentsConfig{Max: defaultMax} + + if configMap, ok := configData.(map[string]any); ok { + // Parse max (only for plural form) + if hasPlural { + if max, exists := configMap["max"]; exists { + if maxInt, ok := c.parseIntValue(max); ok { + commentsConfig.Max = maxInt + } + } + } + } + + return commentsConfig +} + +// parsePullRequestsConfig handles only create-pull-request (singular) configuration +func (c *Compiler) parsePullRequestsConfig(outputMap map[string]any) *CreatePullRequestsConfig { + // Check for singular form only + hasSingular := false + if _, exists := outputMap["create-pull-request"]; exists { + hasSingular = true + } + + // Check for unsupported plural form and return nil (no error, just ignore) + if _, exists := outputMap["create-pull-requests"]; exists { + // Plural form is not supported for pull requests - ignore it + return nil + } + + if !hasSingular { + return nil + } + + configData := outputMap["create-pull-request"] + pullRequestsConfig := &CreatePullRequestsConfig{Max: 1} // Always max 1 for pull requests + + if configMap, ok := configData.(map[string]any); ok { + // Parse title-prefix + if titlePrefix, exists := configMap["title-prefix"]; exists { + if titlePrefixStr, ok := titlePrefix.(string); ok { + pullRequestsConfig.TitlePrefix = titlePrefixStr + } + } + + // Parse labels + if labels, exists := configMap["labels"]; exists { + if labelsArray, ok := labels.([]any); ok { + var labelStrings []string + for _, label := range labelsArray { + if labelStr, ok := label.(string); ok { + labelStrings = append(labelStrings, labelStr) + } + } + pullRequestsConfig.Labels = labelStrings + } + } + + // Parse draft + if draft, exists := configMap["draft"]; exists { + if draftBool, ok := draft.(bool); ok { + pullRequestsConfig.Draft = &draftBool + } + } + + // Note: max parameter is not supported for pull requests (always limited to 1) + // If max is specified, it will be ignored as pull requests are singular only + } + + return pullRequestsConfig +} + +// parseIntValue safely parses various numeric types to int +func (c *Compiler) parseIntValue(value any) (int, bool) { + switch v := value.(type) { + case int: + return v, true + case int64: + return int(v), true + case uint64: + return int(v), true + case float64: + return int(v), true + default: + return 0, false + } +} + // buildCustomJobs creates custom jobs defined in the frontmatter jobs section func (c *Compiler) buildCustomJobs(data *WorkflowData) error { for jobName, jobConfig := range data.Jobs { @@ -2844,13 +2977,13 @@ func (c *Compiler) generateOutputCollectionStep(yaml *strings.Builder, data *Wor if data.SafeOutputs != nil { // Create a simplified config object for validation safeOutputsConfig := make(map[string]interface{}) - if data.SafeOutputs.CreateIssue != nil { + if data.SafeOutputs.CreateIssues != nil { safeOutputsConfig["create-issue"] = true } - if data.SafeOutputs.AddIssueComment != nil { + if data.SafeOutputs.AddIssueComments != nil { safeOutputsConfig["add-issue-comment"] = true } - if data.SafeOutputs.CreatePullRequest != nil { + if data.SafeOutputs.CreatePullRequests != nil { safeOutputsConfig["create-pull-request"] = true } if data.SafeOutputs.AddIssueLabels != nil { diff --git a/pkg/workflow/compiler_test.go b/pkg/workflow/compiler_test.go index 6f823226858..c4adaf799c8 100644 --- a/pkg/workflow/compiler_test.go +++ b/pkg/workflow/compiler_test.go @@ -5428,7 +5428,7 @@ func TestComputeAllowedToolsWithSafeOutputs(t *testing.T) { }, }, safeOutputs: &SafeOutputsConfig{ - CreateIssue: &CreateIssueConfig{}, + CreateIssues: &CreateIssuesConfig{Max: 1}, }, expected: "Read,Write", }, @@ -5443,7 +5443,7 @@ func TestComputeAllowedToolsWithSafeOutputs(t *testing.T) { }, }, safeOutputs: &SafeOutputsConfig{ - CreateIssue: &CreateIssueConfig{}, + CreateIssues: &CreateIssuesConfig{Max: 1}, }, expected: "Read,Write", }, @@ -5469,9 +5469,9 @@ func TestComputeAllowedToolsWithSafeOutputs(t *testing.T) { }, }, safeOutputs: &SafeOutputsConfig{ - CreateIssue: &CreateIssueConfig{}, - AddIssueComment: &AddIssueCommentConfig{}, - CreatePullRequest: &CreatePullRequestConfig{}, + CreateIssues: &CreateIssuesConfig{Max: 1}, + AddIssueComments: &AddIssueCommentsConfig{Max: 1}, + CreatePullRequests: &CreatePullRequestsConfig{Max: 1}, }, expected: "Bash,Write", }, @@ -5488,7 +5488,7 @@ func TestComputeAllowedToolsWithSafeOutputs(t *testing.T) { }, }, safeOutputs: &SafeOutputsConfig{ - CreateIssue: &CreateIssueConfig{}, + CreateIssues: &CreateIssuesConfig{Max: 1}, }, expected: "Read,Write,mcp__github__create_issue,mcp__github__create_pull_request", }, diff --git a/pkg/workflow/js/collect_ndjson_output.cjs b/pkg/workflow/js/collect_ndjson_output.cjs index 894f5da9f09..3ad997fa9f1 100644 --- a/pkg/workflow/js/collect_ndjson_output.cjs +++ b/pkg/workflow/js/collect_ndjson_output.cjs @@ -1,130 +1,157 @@ -/** - * Sanitizes content for safe output in GitHub Actions - * @param {string} content - The content to sanitize - * @returns {string} The sanitized content - */ -function sanitizeContent(content) { - if (!content || typeof content !== 'string') { - return ''; - } +async function main() { + const fs = require("fs"); + + /** + * Sanitizes content for safe output in GitHub Actions + * @param {string} content - The content to sanitize + * @returns {string} The sanitized content + */ + function sanitizeContent(content) { + if (!content || typeof content !== 'string') { + return ''; + } - // Read allowed domains from environment variable - const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; - const defaultAllowedDomains = [ - 'github.com', - 'github.io', - 'githubusercontent.com', - 'githubassets.com', - 'github.dev', - 'codespaces.new' - ]; - - const allowedDomains = allowedDomainsEnv - ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) - : defaultAllowedDomains; - - let sanitized = content; - - // Neutralize @mentions to prevent unintended notifications - sanitized = neutralizeMentions(sanitized); - - // Remove control characters (except newlines and tabs) - sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); - - // XML character escaping - sanitized = sanitized - .replace(/&/g, '&') // Must be first to avoid double-escaping - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - - // URI filtering - replace non-https protocols with "(redacted)" - sanitized = sanitizeUrlProtocols(sanitized); - - // Domain filtering for HTTPS URIs - sanitized = sanitizeUrlDomains(sanitized); - - // Limit total length to prevent DoS (0.5MB max) - const maxLength = 524288; - if (sanitized.length > maxLength) { - sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; - } + // Read allowed domains from environment variable + const allowedDomainsEnv = process.env.GITHUB_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = [ + 'github.com', + 'github.io', + 'githubusercontent.com', + 'githubassets.com', + 'github.dev', + 'codespaces.new' + ]; + + const allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv.split(',').map(d => d.trim()).filter(d => d) + : defaultAllowedDomains; + + let sanitized = content; + + // Neutralize @mentions to prevent unintended notifications + sanitized = neutralizeMentions(sanitized); + + // Remove control characters (except newlines and tabs) + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); + + // XML character escaping + sanitized = sanitized + .replace(/&/g, '&') // Must be first to avoid double-escaping + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + + // URI filtering - replace non-https protocols with "(redacted)" + sanitized = sanitizeUrlProtocols(sanitized); + + // Domain filtering for HTTPS URIs + sanitized = sanitizeUrlDomains(sanitized); + + // Limit total length to prevent DoS (0.5MB max) + const maxLength = 524288; + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + '\n[Content truncated due to length]'; + } - // Limit number of lines to prevent log flooding (65k max) - const lines = sanitized.split('\n'); - const maxLines = 65000; - if (lines.length > maxLines) { - sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; - } + // Limit number of lines to prevent log flooding (65k max) + const lines = sanitized.split('\n'); + const maxLines = 65000; + if (lines.length > maxLines) { + sanitized = lines.slice(0, maxLines).join('\n') + '\n[Content truncated due to line count]'; + } - // Remove ANSI escape sequences - sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); + // Remove ANSI escape sequences + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ''); - // Neutralize common bot trigger phrases - sanitized = neutralizeBotTriggers(sanitized); + // Neutralize common bot trigger phrases + sanitized = neutralizeBotTriggers(sanitized); - // Trim excessive whitespace - return sanitized.trim(); + // Trim excessive whitespace + return sanitized.trim(); - /** - * Remove unknown domains - * @param {string} s - The string to process - * @returns {string} The string with unknown domains redacted - */ - function sanitizeUrlDomains(s) { - return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { - // Extract the hostname part (before first slash, colon, or other delimiter) - const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); - - // Check if this domain or any parent domain is in the allowlist - const isAllowed = allowedDomains.some(allowedDomain => { - const normalizedAllowed = allowedDomain.toLowerCase(); - return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + /** + * Remove unknown domains + * @param {string} s - The string to process + * @returns {string} The string with unknown domains redacted + */ + function sanitizeUrlDomains(s) { + return s.replace(/\bhttps:\/\/([^\/\s\])}'"<>&\x00-\x1f]+)/gi, (match, domain) => { + // Extract the hostname part (before first slash, colon, or other delimiter) + const hostname = domain.split(/[\/:\?#]/)[0].toLowerCase(); + + // Check if this domain or any parent domain is in the allowlist + const isAllowed = allowedDomains.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + return hostname === normalizedAllowed || hostname.endsWith('.' + normalizedAllowed); + }); + + return isAllowed ? match : '(redacted)'; }); + } - return isAllowed ? match : '(redacted)'; - }); - } + /** + * Remove unknown protocols except https + * @param {string} s - The string to process + * @returns {string} The string with non-https protocols redacted + */ + function sanitizeUrlProtocols(s) { + // Match both protocol:// and protocol: patterns + return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { + // Allow https (case insensitive), redact everything else + return protocol.toLowerCase() === 'https' ? match : '(redacted)'; + }); + } - /** - * Remove unknown protocols except https - * @param {string} s - The string to process - * @returns {string} The string with non-https protocols redacted - */ - function sanitizeUrlProtocols(s) { - // Match both protocol:// and protocol: patterns - return s.replace(/\b(\w+):(?:\/\/)?[^\s\])}'"<>&\x00-\x1f]+/gi, (match, protocol) => { - // Allow https (case insensitive), redact everything else - return protocol.toLowerCase() === 'https' ? match : '(redacted)'; - }); - } + /** + * Neutralizes @mentions by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized mentions + */ + function neutralizeMentions(s) { + // Replace @name or @org/team outside code with `@name` + return s.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}\``); + } - /** - * Neutralizes @mentions by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized mentions - */ - function neutralizeMentions(s) { - // Replace @name or @org/team outside code with `@name` - return s.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}\``); + /** + * Neutralizes bot trigger phrases by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized bot triggers + */ + function neutralizeBotTriggers(s) { + // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. + return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, + (match, action, ref) => `\`${action} #${ref}\``); + } } - + /** - * Neutralizes bot trigger phrases by wrapping them in backticks - * @param {string} s - The string to process - * @returns {string} The string with neutralized bot triggers + * Gets the maximum allowed count for a given output type + * @param {string} itemType - The output item type + * @param {Object} config - The safe-outputs configuration + * @returns {number} The maximum allowed count */ - function neutralizeBotTriggers(s) { - // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc. - return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, - (match, action, ref) => `\`${action} #${ref}\``); + function getMaxAllowedForType(itemType, config) { + // Check if max is explicitly specified in config + if (config && config[itemType] && typeof config[itemType] === 'object' && config[itemType].max) { + return config[itemType].max; + } + + // Use default limits for plural-supported types + switch (itemType) { + case 'create-issue': + return 10; // Allow multiple issues + case 'add-issue-comment': + return 10; // Allow multiple comments + case 'create-pull-request': + return 1; // Only one pull request allowed + case 'add-issue-labels': + return 1; // Only one labels operation allowed + default: + return 1; // Default to single item for unknown types + } } -} - -async function main() { - const fs = require("fs"); const outputFile = process.env.GITHUB_AW_SAFE_OUTPUTS; const safeOutputsConfig = process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG; @@ -185,10 +212,11 @@ async function main() { continue; } - // Check for duplicates based on type (since current format allows only one of each) - const existingItem = parsedItems.find(existing => existing.type === itemType); - if (existingItem) { - errors.push(`Line ${i + 1}: Duplicate output type '${itemType}'. Only one item of each type is currently allowed.`); + // Check for too many items of the same type + const typeCount = parsedItems.filter(existing => existing.type === itemType).length; + const maxAllowed = getMaxAllowedForType(itemType, expectedOutputTypes); + if (typeCount >= maxAllowed) { + errors.push(`Line ${i + 1}: Too many items of type '${itemType}'. Maximum allowed: ${maxAllowed}.`); continue; } diff --git a/pkg/workflow/js/collect_ndjson_output.test.cjs b/pkg/workflow/js/collect_ndjson_output.test.cjs index da5f4654275..17f320f4b4a 100644 --- a/pkg/workflow/js/collect_ndjson_output.test.cjs +++ b/pkg/workflow/js/collect_ndjson_output.test.cjs @@ -194,7 +194,7 @@ describe('collect_ndjson_output.cjs', () => { expect(parsedOutput.errors[0]).toContain('Invalid JSON'); }); - it('should reject duplicate output types', async () => { + it('should allow multiple items of supported types up to limits', async () => { const testFile = '/tmp/test-ndjson-output.txt'; const ndjsonContent = `{"type": "create-issue", "title": "First Issue", "body": "First body"} {"type": "create-issue", "title": "Second Issue", "body": "Second body"}`; @@ -210,10 +210,35 @@ describe('collect_ndjson_output.cjs', () => { expect(outputCall).toBeDefined(); const parsedOutput = JSON.parse(outputCall[1]); - expect(parsedOutput.items).toHaveLength(1); + expect(parsedOutput.items).toHaveLength(2); // Both items should be allowed expect(parsedOutput.items[0].title).toBe('First Issue'); - expect(parsedOutput.errors).toHaveLength(1); - expect(parsedOutput.errors[0]).toContain('Duplicate output type'); + expect(parsedOutput.items[1].title).toBe('Second Issue'); + expect(parsedOutput.errors).toHaveLength(0); // No errors for multiple items within limits + }); + + it('should respect max limits from config', async () => { + const testFile = '/tmp/test-ndjson-output.txt'; + const ndjsonContent = `{"type": "create-issue", "title": "First Issue", "body": "First body"} +{"type": "create-issue", "title": "Second Issue", "body": "Second body"} +{"type": "create-issue", "title": "Third Issue", "body": "Third body"}`; + + fs.writeFileSync(testFile, ndjsonContent); + process.env.GITHUB_AW_SAFE_OUTPUTS = testFile; + // Set max to 2 for create-issue + process.env.GITHUB_AW_SAFE_OUTPUTS_CONFIG = '{"create-issue": {"max": 2}}'; + + await eval(`(async () => { ${collectScript} })()`); + + const setOutputCalls = mockCore.setOutput.mock.calls; + const outputCall = setOutputCalls.find(call => call[0] === 'output'); + expect(outputCall).toBeDefined(); + + const parsedOutput = JSON.parse(outputCall[1]); + expect(parsedOutput.items).toHaveLength(2); // Only first 2 items should be allowed + expect(parsedOutput.items[0].title).toBe('First Issue'); + expect(parsedOutput.items[1].title).toBe('Second Issue'); + expect(parsedOutput.errors).toHaveLength(1); // Error for the third item exceeding max + expect(parsedOutput.errors[0]).toContain('Too many items of type \'create-issue\'. Maximum allowed: 2'); }); it('should skip empty lines', async () => { diff --git a/pkg/workflow/js/create_comment.cjs b/pkg/workflow/js/create_comment.cjs index 6dec2454985..979de66118f 100644 --- a/pkg/workflow/js/create_comment.cjs +++ b/pkg/workflow/js/create_comment.cjs @@ -27,14 +27,14 @@ async function main() { return; } - // Find the add-issue-comment item - const commentItem = validatedOutput.items.find(/** @param {any} item */ item => item.type === 'add-issue-comment'); - if (!commentItem) { - console.log('No add-issue-comment item found in agent output'); + // Find all add-issue-comment items + const commentItems = validatedOutput.items.filter(/** @param {any} item */ item => item.type === 'add-issue-comment'); + if (commentItems.length === 0) { + console.log('No add-issue-comment items found in agent output'); return; } - console.log('Found add-issue-comment item:', { bodyLength: commentItem.body.length }); + console.log(`Found ${commentItems.length} add-issue-comment item(s)`); // Check if we're in an issue or pull request context const isIssueContext = context.eventName === 'issues' || context.eventName === 'issue_comment'; @@ -45,66 +45,86 @@ async function main() { return; } - // Determine the issue/PR number and comment endpoint - let issueNumber; - let commentEndpoint; - - if (isIssueContext) { - if (context.payload.issue) { - issueNumber = context.payload.issue.number; - commentEndpoint = 'issues'; - } else { - console.log('Issue context detected but no issue found in payload'); - return; + const createdComments = []; + + // Process each comment item + for (let i = 0; i < commentItems.length; i++) { + const commentItem = commentItems[i]; + console.log(`Processing add-issue-comment item ${i + 1}/${commentItems.length}:`, { bodyLength: commentItem.body.length }); + + // Determine the issue/PR number and comment endpoint for this comment + let issueNumber; + let commentEndpoint; + + if (isIssueContext) { + if (context.payload.issue) { + issueNumber = context.payload.issue.number; + commentEndpoint = 'issues'; + } else { + console.log('Issue context detected but no issue found in payload'); + continue; + } + } else if (isPRContext) { + if (context.payload.pull_request) { + issueNumber = context.payload.pull_request.number; + commentEndpoint = 'issues'; // PR comments use the issues API endpoint + } else { + console.log('Pull request context detected but no pull request found in payload'); + continue; + } } - } else if (isPRContext) { - if (context.payload.pull_request) { - issueNumber = context.payload.pull_request.number; - commentEndpoint = 'issues'; // PR comments use the issues API endpoint - } else { - console.log('Pull request context detected but no pull request found in payload'); - return; + + if (!issueNumber) { + console.log('Could not determine issue or pull request number'); + continue; + } + + // Extract body from the JSON item + let body = commentItem.body.trim(); + // Add AI disclaimer with run id, run htmlurl + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + body += `\n\n> Generated by Agentic Workflow Run [${runId}](${runUrl})\n`; + + console.log(`Creating comment on ${commentEndpoint} #${issueNumber}`); + console.log('Comment content length:', body.length); + + try { + // Create the comment using GitHub API + const { data: comment } = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: body + }); + + console.log('Created comment #' + comment.id + ': ' + comment.html_url); + createdComments.push(comment); + + // Set output for the last created comment (for backward compatibility) + if (i === commentItems.length - 1) { + core.setOutput('comment_id', comment.id); + core.setOutput('comment_url', comment.html_url); + } + } catch (error) { + console.error(`✗ Failed to create comment:`, error instanceof Error ? error.message : String(error)); + throw error; } } - if (!issueNumber) { - console.log('Could not determine issue or pull request number'); - return; + // Write summary for all created comments + if (createdComments.length > 0) { + let summaryContent = '\n\n## GitHub Comments\n'; + for (const comment of createdComments) { + summaryContent += `- Comment #${comment.id}: [View Comment](${comment.html_url})\n`; + } + await core.summary.addRaw(summaryContent).write(); } - // Extract body from the JSON item - let body = commentItem.body.trim(); - // Add AI disclaimer with run id, run htmlurl - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - body += `\n\n> Generated by Agentic Workflow Run [${runId}](${runUrl})\n`; - - console.log(`Creating comment on ${commentEndpoint} #${issueNumber}`); - console.log('Comment content length:', body.length); - - // Create the comment using GitHub API - const { data: comment } = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body: body - }); - - console.log('Created comment #' + comment.id + ': ' + comment.html_url); - - // Set output for other jobs to use - core.setOutput('comment_id', comment.id); - core.setOutput('comment_url', comment.html_url); - - // write comment id, url to the github_step_summary - await core.summary.addRaw(` - -## GitHub Comment -- Comment ID: ${comment.id} -- Comment URL: ${comment.html_url} -`).write(); + console.log(`Successfully created ${createdComments.length} comment(s)`); + return createdComments; } await main(); \ No newline at end of file diff --git a/pkg/workflow/js/create_issue.cjs b/pkg/workflow/js/create_issue.cjs index ed465b377c8..bc64ee700ee 100644 --- a/pkg/workflow/js/create_issue.cjs +++ b/pkg/workflow/js/create_issue.cjs @@ -26,100 +26,120 @@ async function main() { return; } - // Find the create-issue item - const createIssueItem = validatedOutput.items.find(/** @param {any} item */ item => item.type === 'create-issue'); - if (!createIssueItem) { - console.log('No create-issue item found in agent output'); + // Find all create-issue items + const createIssueItems = validatedOutput.items.filter(/** @param {any} item */ item => item.type === 'create-issue'); + if (createIssueItems.length === 0) { + console.log('No create-issue items found in agent output'); return; } - console.log('Found create-issue item:', { title: createIssueItem.title, bodyLength: createIssueItem.body.length }); + console.log(`Found ${createIssueItems.length} create-issue item(s)`); // Check if we're in an issue context (triggered by an issue event) const parentIssueNumber = context.payload?.issue?.number; - // Parse labels from environment variable (comma-separated string) or from the JSON item + // Parse labels from environment variable (comma-separated string) const labelsEnv = process.env.GITHUB_AW_ISSUE_LABELS; - let labels = labelsEnv ? labelsEnv.split(',').map(/** @param {string} label */ label => label.trim()).filter(/** @param {string} label */ label => label) : []; + let envLabels = labelsEnv ? labelsEnv.split(',').map(/** @param {string} label */ label => label.trim()).filter(/** @param {string} label */ label => label) : []; - // If the item has labels, use those instead (or merge them) - if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { - labels = [...labels, ...createIssueItem.labels].filter(Boolean); - } - - // Extract title and body from the JSON item - let title = createIssueItem.title ? createIssueItem.title.trim() : ''; - let bodyLines = createIssueItem.body.split('\n'); - - // If no title was found, use the body content as title (or a default) - if (!title) { - title = createIssueItem.body || 'Agent Output'; - } - - // Apply title prefix if provided via environment variable - const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; - if (titlePrefix && !title.startsWith(titlePrefix)) { - title = titlePrefix + title; - } + const createdIssues = []; + + // Process each create-issue item + for (let i = 0; i < createIssueItems.length; i++) { + const createIssueItem = createIssueItems[i]; + console.log(`Processing create-issue item ${i + 1}/${createIssueItems.length}:`, { title: createIssueItem.title, bodyLength: createIssueItem.body.length }); + + // Merge environment labels with item-specific labels + let labels = [...envLabels]; + if (createIssueItem.labels && Array.isArray(createIssueItem.labels)) { + labels = [...labels, ...createIssueItem.labels].filter(Boolean); + } - if (parentIssueNumber) { - console.log('Detected issue context, parent issue #' + parentIssueNumber); + // Extract title and body from the JSON item + let title = createIssueItem.title ? createIssueItem.title.trim() : ''; + let bodyLines = createIssueItem.body.split('\n'); - // Add reference to parent issue in the child issue body - bodyLines.push(`Related to #${parentIssueNumber}`); - } + // If no title was found, use the body content as title (or a default) + if (!title) { + title = createIssueItem.body || 'Agent Output'; + } - // Add AI disclaimer with run id, run htmlurl - // Add AI disclaimer with workflow run information - const runId = context.runId; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `https://github.com/actions/runs/${runId}`; - bodyLines.push(``, ``, `> Generated by Agentic Workflow Run [${runId}](${runUrl})`, ''); + // Apply title prefix if provided via environment variable + const titlePrefix = process.env.GITHUB_AW_ISSUE_TITLE_PREFIX; + if (titlePrefix && !title.startsWith(titlePrefix)) { + title = titlePrefix + title; + } - // Prepare the body content - const body = bodyLines.join('\n').trim(); + if (parentIssueNumber) { + console.log('Detected issue context, parent issue #' + parentIssueNumber); - console.log('Creating issue with title:', title); - console.log('Labels:', labels); - console.log('Body length:', body.length); + // Add reference to parent issue in the child issue body + bodyLines.push(`Related to #${parentIssueNumber}`); + } + // Add AI disclaimer with run id, run htmlurl + // Add AI disclaimer with workflow run information + const runId = context.runId; + const runUrl = context.payload.repository + ? `${context.payload.repository.html_url}/actions/runs/${runId}` + : `https://github.com/actions/runs/${runId}`; + bodyLines.push(``, ``, `> Generated by Agentic Workflow Run [${runId}](${runUrl})`, ''); - // Create the issue using GitHub API - const { data: issue } = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body, - labels: labels - }); + // Prepare the body content + const body = bodyLines.join('\n').trim(); - console.log('Created issue #' + issue.number + ': ' + issue.html_url); + console.log('Creating issue with title:', title); + console.log('Labels:', labels); + console.log('Body length:', body.length); - // If we have a parent issue, add a comment to it referencing the new child issue - if (parentIssueNumber) { try { - await github.rest.issues.createComment({ + // Create the issue using GitHub API + const { data: issue } = await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: parentIssueNumber, - body: `Created related issue: #${issue.number}` + title: title, + body: body, + labels: labels }); - console.log('Added comment to parent issue #' + parentIssueNumber); + + console.log('Created issue #' + issue.number + ': ' + issue.html_url); + createdIssues.push(issue); + + // If we have a parent issue, add a comment to it referencing the new child issue + if (parentIssueNumber) { + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parentIssueNumber, + body: `Created related issue: #${issue.number}` + }); + console.log('Added comment to parent issue #' + parentIssueNumber); + } catch (error) { + console.log('Warning: Could not add comment to parent issue:', error instanceof Error ? error.message : String(error)); + } + } + + // Set output for the last created issue (for backward compatibility) + if (i === createIssueItems.length - 1) { + core.setOutput('issue_number', issue.number); + core.setOutput('issue_url', issue.html_url); + } } catch (error) { - console.log('Warning: Could not add comment to parent issue:', error instanceof Error ? error.message : String(error)); + console.error(`✗ Failed to create issue "${title}":`, error instanceof Error ? error.message : String(error)); + throw error; } } - // Set output for other jobs to use - core.setOutput('issue_number', issue.number); - core.setOutput('issue_url', issue.html_url); - // write issue to summary - await core.summary.addRaw(` + // Write summary for all created issues + if (createdIssues.length > 0) { + let summaryContent = '\n\n## GitHub Issues\n'; + for (const issue of createdIssues) { + summaryContent += `- Issue #${issue.number}: [${issue.title}](${issue.html_url})\n`; + } + await core.summary.addRaw(summaryContent).write(); + } -## GitHub Issue -- Issue ID: ${issue.number} -- Issue URL: ${issue.html_url} -`).write(); + console.log(`Successfully created ${createdIssues.length} issue(s)`); } await main(); \ No newline at end of file diff --git a/pkg/workflow/output_test.go b/pkg/workflow/output_test.go index b19014ab0cc..65fc0d26114 100644 --- a/pkg/workflow/output_test.go +++ b/pkg/workflow/output_test.go @@ -51,25 +51,25 @@ This workflow tests the output configuration parsing. t.Fatal("Expected output configuration to be parsed") } - if workflowData.SafeOutputs.CreateIssue == nil { + if workflowData.SafeOutputs.CreateIssues == nil { t.Fatal("Expected issue configuration to be parsed") } // Verify title prefix expectedPrefix := "[genai] " - if workflowData.SafeOutputs.CreateIssue.TitlePrefix != expectedPrefix { - t.Errorf("Expected title prefix '%s', got '%s'", expectedPrefix, workflowData.SafeOutputs.CreateIssue.TitlePrefix) + if workflowData.SafeOutputs.CreateIssues.TitlePrefix != expectedPrefix { + t.Errorf("Expected title prefix '%s', got '%s'", expectedPrefix, workflowData.SafeOutputs.CreateIssues.TitlePrefix) } // Verify labels expectedLabels := []string{"copilot", "automation"} - if len(workflowData.SafeOutputs.CreateIssue.Labels) != len(expectedLabels) { - t.Errorf("Expected %d labels, got %d", len(expectedLabels), len(workflowData.SafeOutputs.CreateIssue.Labels)) + if len(workflowData.SafeOutputs.CreateIssues.Labels) != len(expectedLabels) { + t.Errorf("Expected %d labels, got %d", len(expectedLabels), len(workflowData.SafeOutputs.CreateIssues.Labels)) } for i, expectedLabel := range expectedLabels { - if i >= len(workflowData.SafeOutputs.CreateIssue.Labels) || workflowData.SafeOutputs.CreateIssue.Labels[i] != expectedLabel { - t.Errorf("Expected label '%s' at index %d, got '%s'", expectedLabel, i, workflowData.SafeOutputs.CreateIssue.Labels[i]) + if i >= len(workflowData.SafeOutputs.CreateIssues.Labels) || workflowData.SafeOutputs.CreateIssues.Labels[i] != expectedLabel { + t.Errorf("Expected label '%s' at index %d, got '%s'", expectedLabel, i, workflowData.SafeOutputs.CreateIssues.Labels[i]) } } } @@ -162,29 +162,29 @@ This workflow tests the null output configuration parsing. } // Verify create-issue configuration is parsed with empty values - if workflowData.SafeOutputs.CreateIssue == nil { + if workflowData.SafeOutputs.CreateIssues == nil { t.Fatal("Expected create-issue configuration to be parsed with null value") } - if workflowData.SafeOutputs.CreateIssue.TitlePrefix != "" { - t.Errorf("Expected empty title prefix for null create-issue, got '%s'", workflowData.SafeOutputs.CreateIssue.TitlePrefix) + if workflowData.SafeOutputs.CreateIssues.TitlePrefix != "" { + t.Errorf("Expected empty title prefix for null create-issue, got '%s'", workflowData.SafeOutputs.CreateIssues.TitlePrefix) } - if len(workflowData.SafeOutputs.CreateIssue.Labels) != 0 { - t.Errorf("Expected empty labels for null create-issue, got %v", workflowData.SafeOutputs.CreateIssue.Labels) + if len(workflowData.SafeOutputs.CreateIssues.Labels) != 0 { + t.Errorf("Expected empty labels for null create-issue, got %v", workflowData.SafeOutputs.CreateIssues.Labels) } // Verify create-pull-request configuration is parsed with empty values - if workflowData.SafeOutputs.CreatePullRequest == nil { + if workflowData.SafeOutputs.CreatePullRequests == nil { t.Fatal("Expected create-pull-request configuration to be parsed with null value") } - if workflowData.SafeOutputs.CreatePullRequest.TitlePrefix != "" { - t.Errorf("Expected empty title prefix for null create-pull-request, got '%s'", workflowData.SafeOutputs.CreatePullRequest.TitlePrefix) + if workflowData.SafeOutputs.CreatePullRequests.TitlePrefix != "" { + t.Errorf("Expected empty title prefix for null create-pull-request, got '%s'", workflowData.SafeOutputs.CreatePullRequests.TitlePrefix) } - if len(workflowData.SafeOutputs.CreatePullRequest.Labels) != 0 { - t.Errorf("Expected empty labels for null create-pull-request, got %v", workflowData.SafeOutputs.CreatePullRequest.Labels) + if len(workflowData.SafeOutputs.CreatePullRequests.Labels) != 0 { + t.Errorf("Expected empty labels for null create-pull-request, got %v", workflowData.SafeOutputs.CreatePullRequests.Labels) } // Verify add-issue-comment configuration is parsed with empty values - if workflowData.SafeOutputs.AddIssueComment == nil { + if workflowData.SafeOutputs.AddIssueComments == nil { t.Fatal("Expected add-issue-comment configuration to be parsed with null value") } @@ -332,7 +332,7 @@ This workflow tests the output.add-issue-comment configuration parsing. t.Fatal("Expected output configuration to be parsed") } - if workflowData.SafeOutputs.AddIssueComment == nil { + if workflowData.SafeOutputs.AddIssueComments == nil { t.Fatal("Expected issue_comment configuration to be parsed") } } @@ -382,7 +382,7 @@ This workflow tests the output.add-issue-comment configuration parsing with null t.Fatal("Expected output configuration to be parsed") } - if workflowData.SafeOutputs.AddIssueComment == nil { + if workflowData.SafeOutputs.AddIssueComments == nil { t.Fatal("Expected issue_comment configuration to be parsed even with null value") } } @@ -580,25 +580,25 @@ This workflow tests the output pull request configuration parsing. t.Fatal("Expected output configuration to be parsed") } - if workflowData.SafeOutputs.CreatePullRequest == nil { + if workflowData.SafeOutputs.CreatePullRequests == nil { t.Fatal("Expected pull-request configuration to be parsed") } // Verify title prefix expectedPrefix := "[agent] " - if workflowData.SafeOutputs.CreatePullRequest.TitlePrefix != expectedPrefix { - t.Errorf("Expected title prefix '%s', got '%s'", expectedPrefix, workflowData.SafeOutputs.CreatePullRequest.TitlePrefix) + if workflowData.SafeOutputs.CreatePullRequests.TitlePrefix != expectedPrefix { + t.Errorf("Expected title prefix '%s', got '%s'", expectedPrefix, workflowData.SafeOutputs.CreatePullRequests.TitlePrefix) } // Verify labels expectedLabels := []string{"automation", "bot"} - if len(workflowData.SafeOutputs.CreatePullRequest.Labels) != len(expectedLabels) { - t.Errorf("Expected %d labels, got %d", len(expectedLabels), len(workflowData.SafeOutputs.CreatePullRequest.Labels)) + if len(workflowData.SafeOutputs.CreatePullRequests.Labels) != len(expectedLabels) { + t.Errorf("Expected %d labels, got %d", len(expectedLabels), len(workflowData.SafeOutputs.CreatePullRequests.Labels)) } for i, expectedLabel := range expectedLabels { - if i >= len(workflowData.SafeOutputs.CreatePullRequest.Labels) || workflowData.SafeOutputs.CreatePullRequest.Labels[i] != expectedLabel { - t.Errorf("Expected label[%d] to be '%s', got '%s'", i, expectedLabel, workflowData.SafeOutputs.CreatePullRequest.Labels[i]) + if i >= len(workflowData.SafeOutputs.CreatePullRequests.Labels) || workflowData.SafeOutputs.CreatePullRequests.Labels[i] != expectedLabel { + t.Errorf("Expected label[%d] to be '%s', got '%s'", i, expectedLabel, workflowData.SafeOutputs.CreatePullRequests.Labels[i]) } } } diff --git a/pkg/workflow/plural_safe_outputs_integration_test.go b/pkg/workflow/plural_safe_outputs_integration_test.go new file mode 100644 index 00000000000..3d21f6c01dd --- /dev/null +++ b/pkg/workflow/plural_safe_outputs_integration_test.go @@ -0,0 +1,213 @@ +package workflow + +import ( + "testing" + + "github.com/githubnext/gh-aw/pkg/parser" +) + +func TestSafeOutputsBackwardCompatibility(t *testing.T) { + compiler := &Compiler{} + + t.Run("Legacy singular syntax should still work", func(t *testing.T) { + content := `--- +safe-outputs: + create-issue: + title-prefix: "[Auto] " + labels: ["bug", "auto-generated"] + add-issue-comment: + create-pull-request: + title-prefix: "[Fix] " + draft: true +--- + +# Test workflow + +This workflow should work with legacy syntax. +` + + // Parse the workflow content + result, err := parser.ExtractFrontmatterFromContent(content) + if err != nil { + t.Fatalf("Failed to parse frontmatter: %v", err) + } + + config := compiler.extractSafeOutputsConfig(result.Frontmatter) + if config == nil { + t.Fatal("Expected config to be parsed") + } + + // Verify create-issue (singular) is converted to create-issues with max: 1 + if config.CreateIssues == nil { + t.Fatal("Expected CreateIssues to be parsed from legacy create-issue") + } + if config.CreateIssues.Max != 1 { + t.Errorf("Expected CreateIssues.Max to be 1 for legacy syntax, got %d", config.CreateIssues.Max) + } + if config.CreateIssues.TitlePrefix != "[Auto] " { + t.Errorf("Expected TitlePrefix '[Auto] ', got '%s'", config.CreateIssues.TitlePrefix) + } + if len(config.CreateIssues.Labels) != 2 { + t.Errorf("Expected 2 labels, got %d", len(config.CreateIssues.Labels)) + } + + // Verify add-issue-comment (singular) is converted to add-issue-comments with max: 1 + if config.AddIssueComments == nil { + t.Fatal("Expected AddIssueComments to be parsed from legacy add-issue-comment") + } + if config.AddIssueComments.Max != 1 { + t.Errorf("Expected AddIssueComments.Max to be 1 for legacy syntax, got %d", config.AddIssueComments.Max) + } + + // Verify create-pull-request (singular) stays as max: 1 + if config.CreatePullRequests == nil { + t.Fatal("Expected CreatePullRequests to be parsed from create-pull-request") + } + if config.CreatePullRequests.Max != 1 { + t.Errorf("Expected CreatePullRequests.Max to be 1 for singular syntax, got %d", config.CreatePullRequests.Max) + } + if config.CreatePullRequests.TitlePrefix != "[Fix] " { + t.Errorf("Expected TitlePrefix '[Fix] ', got '%s'", config.CreatePullRequests.TitlePrefix) + } + if config.CreatePullRequests.Draft == nil || *config.CreatePullRequests.Draft != true { + t.Errorf("Expected Draft to be true, got %v", config.CreatePullRequests.Draft) + } + }) + + t.Run("New plural syntax should work", func(t *testing.T) { + content := `--- +safe-outputs: + create-issues: + title-prefix: "[Batch] " + labels: ["enhancement"] + max: 5 + add-issue-comments: + max: 3 + create-pull-request: + title-prefix: "[Single] " + draft: false +--- + +# Test workflow + +This workflow uses the new plural syntax for issues and comments, singular for pull requests. +` + + // Parse the workflow content + result, err := parser.ExtractFrontmatterFromContent(content) + if err != nil { + t.Fatalf("Failed to parse frontmatter: %v", err) + } + + config := compiler.extractSafeOutputsConfig(result.Frontmatter) + if config == nil { + t.Fatal("Expected config to be parsed") + } + + // Verify create-issues (plural) with explicit max + if config.CreateIssues == nil { + t.Fatal("Expected CreateIssues to be parsed") + } + if config.CreateIssues.Max != 5 { + t.Errorf("Expected CreateIssues.Max to be 5, got %d", config.CreateIssues.Max) + } + if config.CreateIssues.TitlePrefix != "[Batch] " { + t.Errorf("Expected TitlePrefix '[Batch] ', got '%s'", config.CreateIssues.TitlePrefix) + } + + // Verify add-issue-comments (plural) with explicit max + if config.AddIssueComments == nil { + t.Fatal("Expected AddIssueComments to be parsed") + } + if config.AddIssueComments.Max != 3 { + t.Errorf("Expected AddIssueComments.Max to be 3, got %d", config.AddIssueComments.Max) + } + + // Verify create-pull-request (singular) is always max: 1 + if config.CreatePullRequests == nil { + t.Fatal("Expected CreatePullRequests to be parsed") + } + if config.CreatePullRequests.Max != 1 { + t.Errorf("Expected CreatePullRequests.Max to be 1 (singular), got %d", config.CreatePullRequests.Max) + } + if config.CreatePullRequests.Draft == nil || *config.CreatePullRequests.Draft != false { + t.Errorf("Expected Draft to be false, got %v", config.CreatePullRequests.Draft) + } + }) + + t.Run("Plural syntax without explicit max should default to 10", func(t *testing.T) { + content := `--- +safe-outputs: + create-issues: + add-issue-comments: + create-pull-request: +--- + +# Test workflow + +This workflow uses plural syntax without explicit max values (except pull request which is always singular). +` + + // Parse the workflow content + result, err := parser.ExtractFrontmatterFromContent(content) + if err != nil { + t.Fatalf("Failed to parse frontmatter: %v", err) + } + + config := compiler.extractSafeOutputsConfig(result.Frontmatter) + if config == nil { + t.Fatal("Expected config to be parsed") + } + + // Issues and comments should default to max: 10, pull requests always max: 1 + if config.CreateIssues == nil || config.CreateIssues.Max != 10 { + t.Errorf("Expected CreateIssues.Max to be 10, got %d", config.CreateIssues.Max) + } + if config.AddIssueComments == nil || config.AddIssueComments.Max != 10 { + t.Errorf("Expected AddIssueComments.Max to be 10, got %d", config.AddIssueComments.Max) + } + if config.CreatePullRequests == nil || config.CreatePullRequests.Max != 1 { + t.Errorf("Expected CreatePullRequests.Max to be 1 (singular), got %d", config.CreatePullRequests.Max) + } + }) +} + +func TestWorkflowCompilationWithPluralSafeOutputs(t *testing.T) { + t.Run("Workflow should parse plural safe-outputs configuration", func(t *testing.T) { + content := `--- +safe-outputs: + create-issues: + max: 2 + add-issue-comments: + max: 4 +--- + +# Test workflow + +This workflow uses plural safe-outputs and should compile successfully. + +Analyze the repository and create issues for any problems found. +` + + // Parse just the frontmatter content + result, err := parser.ExtractFrontmatterFromContent(content) + if err != nil { + t.Fatalf("Failed to parse frontmatter: %v", err) + } + + // Extract safe-outputs configuration + compiler := &Compiler{} + config := compiler.extractSafeOutputsConfig(result.Frontmatter) + + // Verify the configuration was parsed correctly + if config == nil { + t.Fatal("Expected SafeOutputs to be parsed") + } + if config.CreateIssues == nil || config.CreateIssues.Max != 2 { + t.Errorf("Expected CreateIssues.Max to be 2, got %v", config.CreateIssues) + } + if config.AddIssueComments == nil || config.AddIssueComments.Max != 4 { + t.Errorf("Expected AddIssueComments.Max to be 4, got %v", config.AddIssueComments) + } + }) +} diff --git a/pkg/workflow/plural_safe_outputs_test.go b/pkg/workflow/plural_safe_outputs_test.go new file mode 100644 index 00000000000..5f242fad663 --- /dev/null +++ b/pkg/workflow/plural_safe_outputs_test.go @@ -0,0 +1,206 @@ +package workflow + +import ( + "testing" +) + +func TestPluralSafeOutputs(t *testing.T) { + compiler := &Compiler{} + + t.Run("Singular forms should convert to max: 1", func(t *testing.T) { + testSingular := map[string]any{ + "safe-outputs": map[string]any{ + "create-issue": nil, + "add-issue-comment": nil, + "create-pull-request": nil, + }, + } + + config := compiler.extractSafeOutputsConfig(testSingular) + if config == nil { + t.Fatal("Expected config to be parsed") + } + + if config.CreateIssues == nil { + t.Fatal("Expected CreateIssues to be parsed") + } + if config.CreateIssues.Max != 1 { + t.Errorf("Expected CreateIssues.Max to be 1 for singular form, got %d", config.CreateIssues.Max) + } + + if config.AddIssueComments == nil { + t.Fatal("Expected AddIssueComments to be parsed") + } + if config.AddIssueComments.Max != 1 { + t.Errorf("Expected AddIssueComments.Max to be 1 for singular form, got %d", config.AddIssueComments.Max) + } + + if config.CreatePullRequests == nil { + t.Fatal("Expected CreatePullRequests to be parsed") + } + if config.CreatePullRequests.Max != 1 { + t.Errorf("Expected CreatePullRequests.Max to be 1 for singular form, got %d", config.CreatePullRequests.Max) + } + }) + + t.Run("Plural forms should default to max: 10", func(t *testing.T) { + testPlural := map[string]any{ + "safe-outputs": map[string]any{ + "create-issues": nil, + "add-issue-comments": nil, + "create-pull-request": nil, // Note: singular, not plural + }, + } + + config := compiler.extractSafeOutputsConfig(testPlural) + if config == nil { + t.Fatal("Expected config to be parsed") + } + + if config.CreateIssues == nil { + t.Fatal("Expected CreateIssues to be parsed") + } + if config.CreateIssues.Max != 10 { + t.Errorf("Expected CreateIssues.Max to be 10 for plural form, got %d", config.CreateIssues.Max) + } + + if config.AddIssueComments == nil { + t.Fatal("Expected AddIssueComments to be parsed") + } + if config.AddIssueComments.Max != 10 { + t.Errorf("Expected AddIssueComments.Max to be 10 for plural form, got %d", config.AddIssueComments.Max) + } + + if config.CreatePullRequests == nil { + t.Fatal("Expected CreatePullRequests to be parsed") + } + if config.CreatePullRequests.Max != 1 { + t.Errorf("Expected CreatePullRequests.Max to be 1 for singular form, got %d", config.CreatePullRequests.Max) + } + }) + + t.Run("Plural forms with explicit max should use provided value", func(t *testing.T) { + testPluralMax := map[string]any{ + "safe-outputs": map[string]any{ + "create-issues": map[string]any{ + "max": 3, + }, + "add-issue-comments": map[string]any{ + "max": 5, + }, + "create-pull-request": map[string]any{ + // max parameter is ignored for pull requests + "max": 2, + }, + }, + } + + config := compiler.extractSafeOutputsConfig(testPluralMax) + if config == nil { + t.Fatal("Expected config to be parsed") + } + + if config.CreateIssues == nil { + t.Fatal("Expected CreateIssues to be parsed") + } + if config.CreateIssues.Max != 3 { + t.Errorf("Expected CreateIssues.Max to be 3, got %d", config.CreateIssues.Max) + } + + if config.AddIssueComments == nil { + t.Fatal("Expected AddIssueComments to be parsed") + } + if config.AddIssueComments.Max != 5 { + t.Errorf("Expected AddIssueComments.Max to be 5, got %d", config.AddIssueComments.Max) + } + + if config.CreatePullRequests == nil { + t.Fatal("Expected CreatePullRequests to be parsed") + } + if config.CreatePullRequests.Max != 1 { + t.Errorf("Expected CreatePullRequests.Max to be 1 (max ignored for pull requests), got %d", config.CreatePullRequests.Max) + } + }) + + t.Run("Mixed configurations should work correctly", func(t *testing.T) { + testMixed := map[string]any{ + "safe-outputs": map[string]any{ + "create-issues": map[string]any{ + "title-prefix": "[Auto] ", + "labels": []any{"bug", "auto-generated"}, + "max": 2, + }, + "create-pull-request": map[string]any{ + "title-prefix": "[Fix] ", + "labels": []any{"fix"}, + "draft": true, + }, + }, + } + + config := compiler.extractSafeOutputsConfig(testMixed) + if config == nil { + t.Fatal("Expected config to be parsed") + } + + // Check plural create-issues + if config.CreateIssues == nil { + t.Fatal("Expected CreateIssues to be parsed") + } + if config.CreateIssues.Max != 2 { + t.Errorf("Expected CreateIssues.Max to be 2, got %d", config.CreateIssues.Max) + } + if config.CreateIssues.TitlePrefix != "[Auto] " { + t.Errorf("Expected CreateIssues.TitlePrefix to be '[Auto] ', got '%s'", config.CreateIssues.TitlePrefix) + } + if len(config.CreateIssues.Labels) != 2 || config.CreateIssues.Labels[0] != "bug" || config.CreateIssues.Labels[1] != "auto-generated" { + t.Errorf("Expected CreateIssues.Labels to be ['bug', 'auto-generated'], got %v", config.CreateIssues.Labels) + } + + // Check singular create-pull-request (should convert to max: 1) + if config.CreatePullRequests == nil { + t.Fatal("Expected CreatePullRequests to be parsed") + } + if config.CreatePullRequests.Max != 1 { + t.Errorf("Expected CreatePullRequests.Max to be 1 for singular form, got %d", config.CreatePullRequests.Max) + } + if config.CreatePullRequests.TitlePrefix != "[Fix] " { + t.Errorf("Expected CreatePullRequests.TitlePrefix to be '[Fix] ', got '%s'", config.CreatePullRequests.TitlePrefix) + } + if len(config.CreatePullRequests.Labels) != 1 || config.CreatePullRequests.Labels[0] != "fix" { + t.Errorf("Expected CreatePullRequests.Labels to be ['fix'], got %v", config.CreatePullRequests.Labels) + } + if config.CreatePullRequests.Draft == nil || *config.CreatePullRequests.Draft != true { + t.Errorf("Expected CreatePullRequests.Draft to be true, got %v", config.CreatePullRequests.Draft) + } + }) + + t.Run("Should prefer plural form when both singular and plural are present", func(t *testing.T) { + testBoth := map[string]any{ + "safe-outputs": map[string]any{ + "create-issue": map[string]any{ + "title-prefix": "[Singular] ", + }, + "create-issues": map[string]any{ + "title-prefix": "[Plural] ", + "max": 5, + }, + }, + } + + config := compiler.extractSafeOutputsConfig(testBoth) + if config == nil { + t.Fatal("Expected config to be parsed") + } + + if config.CreateIssues == nil { + t.Fatal("Expected CreateIssues to be parsed") + } + if config.CreateIssues.Max != 5 { + t.Errorf("Expected CreateIssues.Max to be 5 (from plural form), got %d", config.CreateIssues.Max) + } + if config.CreateIssues.TitlePrefix != "[Plural] " { + t.Errorf("Expected CreateIssues.TitlePrefix to be '[Plural] ' (from plural form), got '%s'", config.CreateIssues.TitlePrefix) + } + }) +}