From 1d6d5298d30033a6116d744100be8fafe71de574 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 06:52:48 +0000 Subject: [PATCH 1/5] Initial plan From b826ee6a9fef4086e04979b783e624723f517558 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 06:57:26 +0000 Subject: [PATCH 2/5] Initial exploration of JavaScript formatting infrastructure Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- ...ze-reduction-project64.campaign.g.lock.yml | 571 +++--------------- 1 file changed, 82 insertions(+), 489 deletions(-) diff --git a/.github/workflows/go-file-size-reduction-project64.campaign.g.lock.yml b/.github/workflows/go-file-size-reduction-project64.campaign.g.lock.yml index bef2ab45c61..36ab6dfa37e 100644 --- a/.github/workflows/go-file-size-reduction-project64.campaign.g.lock.yml +++ b/.github/workflows/go-file-size-reduction-project64.campaign.g.lock.yml @@ -19,82 +19,7 @@ # gh aw compile # For more information: https://github.com/githubnext/gh-aw/blob/main/.github/aw/github-agentic-workflows.md # -# # Systematically reduce oversized Go files to improve maintainability. Success: all files ≤800 LOC, maintain coverage, no regressions. -# -# Original Frontmatter: -# ```yaml -# name: "Go File Size Reduction Campaign (Project 64)" -# description: "Systematically reduce oversized Go files to improve maintainability. Success: all files ≤800 LOC, maintain coverage, no regressions." -# on: -# schedule: -# - cron: "0 18 * * *" -# workflow_dispatch: -# engine: copilot -# safe-outputs: -# add-comment: -# max: 10 -# update-project: -# max: 10 -# runs-on: ubuntu-latest -# roles: -# - "admin" -# - "maintainer" -# - "write" -# ``` -# -# Job Dependency Graph: -# ```mermaid -# graph LR -# activation["activation"] -# add_comment["add_comment"] -# agent["agent"] -# conclusion["conclusion"] -# detection["detection"] -# update_project["update_project"] -# activation --> agent -# activation --> conclusion -# add_comment --> conclusion -# agent --> add_comment -# agent --> conclusion -# agent --> detection -# agent --> update_project -# detection --> add_comment -# detection --> conclusion -# detection --> update_project -# update_project --> conclusion -# ``` -# -# Original Prompt: -# ```markdown -# # Campaign Orchestrator -# -# This workflow orchestrates the 'Go File Size Reduction Campaign (Project 64)' campaign. -# -# - Tracker label: `campaign:go-file-size-reduction-project64` -# - Associated workflows: daily-file-diet -# - Memory paths: memory/campaigns/go-file-size-reduction-project64-*/** -# - Metrics glob: `memory/campaigns/go-file-size-reduction-project64-*/metrics/*.json` -# - Project URL: https://github.com/orgs/githubnext/projects/64 -# -# Each time this orchestrator runs on its daily schedule (or when manually dispatched), generate a concise status report for this campaign. Summarize current metrics and update any tracker issues using the campaign label. -# -# If all issues with the campaign label are closed, the campaign is complete. This is a normal terminal state indicating successful completion, not a blocker or error. When the campaign is complete, mark the project as finished and take no further action. Do not report closed issues as blockers. -# -# Keep the campaign Project dashboard in sync using the `update-project` safe output. When calling update-project, use the `project` field with this exact URL: https://github.com/orgs/githubnext/projects/64 -# -# Use these details to coordinate workers, update metrics, and track progress for this campaign. -# ``` -# -# Pinned GitHub Actions: -# - actions/checkout@v5 (93cb6efe18208431cddfb8368fd83d5badbf9bfd) -# https://github.com/actions/checkout/commit/93cb6efe18208431cddfb8368fd83d5badbf9bfd -# - actions/download-artifact@v6 (018cc2cf5baa6db3ef3c5f8a56943fffe632ef53) -# https://github.com/actions/download-artifact/commit/018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 -# - actions/github-script@v8 (ed597411d8f924073f98dfc5c65a23a2325f34cd) -# https://github.com/actions/github-script/commit/ed597411d8f924073f98dfc5c65a23a2325f34cd -# - actions/upload-artifact@v5 (330a01c490aca151604b8cf639adc76d48f6c5d4) -# https://github.com/actions/upload-artifact/commit/330a01c490aca151604b8cf639adc76d48f6c5d4 name: "Go File Size Reduction Campaign (Project 64)" "on": @@ -188,9 +113,7 @@ jobs: .addRaw("**Files:**\n") .addRaw(`- Source: \`${workflowMdPath}\`\n`) .addRaw(` - Last commit: ${workflowTimestamp}\n`) - .addRaw( - ` - Commit SHA: [\`${workflowCommit.sha.substring(0, 7)}\`](https://github.com/${owner}/${repo}/commit/${workflowCommit.sha})\n` - ) + .addRaw(` - Commit SHA: [\`${workflowCommit.sha.substring(0, 7)}\`](https://github.com/${owner}/${repo}/commit/${workflowCommit.sha})\n`) .addRaw(`- Lock: \`${lockFilePath}\`\n`) .addRaw(` - Last commit: ${lockTimestamp}\n`) .addRaw(` - Commit SHA: [\`${lockCommit.sha.substring(0, 7)}\`](https://github.com/${owner}/${repo}/commit/${lockCommit.sha})\n\n`) @@ -340,11 +263,8 @@ jobs: } const messages = getMessages(); const templateContext = toSnakeCase(ctx); - const defaultInstall = - "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; - return messages?.footerInstall - ? renderTemplate(messages.footerInstall, templateContext) - : renderTemplate(defaultInstall, templateContext); + const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!"; + return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext); } function generateXMLMarker(workflowName, runUrl) { const engineId = process.env.GH_AW_ENGINE_ID || ""; @@ -368,15 +288,7 @@ jobs: parts.push(`run: ${runUrl}`); return ``; } - function generateFooterWithMessages( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ) { + function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) { let triggeringNumber; if (triggeringIssueNumber) { triggeringNumber = triggeringIssueNumber; @@ -723,10 +635,7 @@ jobs: const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering"; core.info(`Comment target configuration: ${commentTarget}`); const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment"; - const isPRContext = - context.eventName === "pull_request" || - context.eventName === "pull_request_review" || - context.eventName === "pull_request_review_comment"; + const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment"; const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment"; const isDiscussion = isDiscussionContext || isDiscussionExplicit; const workflowId = process.env.GITHUB_WORKFLOW || ""; @@ -795,10 +704,8 @@ jobs: core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); return; } - const triggeringIssueNumber = - context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; - const triggeringPRNumber = - context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); + const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined; + const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined); const triggeringDiscussionNumber = context.payload?.discussion?.number; const createdComments = []; for (let i = 0; i < commentItems.length; i++) { @@ -886,9 +793,7 @@ jobs: const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || ""; const runId = context.runId; const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com"; - const runUrl = context.payload.repository - ? `${context.payload.repository.html_url}/actions/runs/${runId}` - : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; + const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`; if (workflowId) { body += `\n\n`; } @@ -897,28 +802,11 @@ jobs: body += trackerIDComment; } body += `\n\n`; - body += generateFooterWithMessages( - workflowName, - runUrl, - workflowSource, - workflowSourceURL, - triggeringIssueNumber, - triggeringPRNumber, - triggeringDiscussionNumber - ); + body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber); try { if (hideOlderCommentsEnabled && workflowId) { core.info("Hide-older-comments is enabled, searching for previous comments to hide"); - await hideOlderComments( - github, - context.repo.owner, - context.repo.repo, - itemNumber, - workflowId, - commentEndpoint === "discussions", - "outdated", - allowedReasons - ); + await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons); } let comment; if (commentEndpoint === "discussions") { @@ -1484,9 +1372,7 @@ jobs: server.debug(` [${toolName}] Python handler args: ${JSON.stringify(args)}`); server.debug(` [${toolName}] Timeout: ${timeoutSeconds}s`); const inputJson = JSON.stringify(args || {}); - server.debug( - ` [${toolName}] Input JSON (${inputJson.length} bytes): ${inputJson.substring(0, 200)}${inputJson.length > 200 ? "..." : ""}` - ); + server.debug(` [${toolName}] Input JSON (${inputJson.length} bytes): ${inputJson.substring(0, 200)}${inputJson.length > 200 ? "..." : ""}`); return new Promise((resolve, reject) => { server.debug(` [${toolName}] Executing Python script...`); const child = execFile( @@ -1594,9 +1480,7 @@ jobs: try { if (fs.existsSync(outputFile)) { const outputContent = fs.readFileSync(outputFile, "utf-8"); - server.debug( - ` [${toolName}] Output file content: ${outputContent.substring(0, 500)}${outputContent.length > 500 ? "..." : ""}` - ); + server.debug(` [${toolName}] Output file content: ${outputContent.substring(0, 500)}${outputContent.length > 500 ? "..." : ""}`); const lines = outputContent.split("\n"); for (const line of lines) { const trimmed = line.trim(); @@ -1654,10 +1538,7 @@ jobs: fs.mkdirSync(server.logDir, { recursive: true }); } const timestamp = new Date().toISOString(); - fs.writeFileSync( - server.logFilePath, - `# ${server.serverInfo.name} MCP Server Log\n# Started: ${timestamp}\n# Version: ${server.serverInfo.version}\n\n` - ); + fs.writeFileSync(server.logFilePath, `# ${server.serverInfo.name} MCP Server Log\n# Started: ${timestamp}\n# Version: ${server.serverInfo.version}\n\n`); server.logFileInitialized = true; } catch { } @@ -2317,10 +2198,7 @@ jobs: const isInWorkspace = absolutePath.startsWith(path.resolve(workspaceDir)); const isInTmp = absolutePath.startsWith(tmpDir); if (!isInWorkspace && !isInTmp) { - throw new Error( - `File path must be within workspace directory (${workspaceDir}) or /tmp directory. ` + - `Provided path: ${filePath} (resolved to: ${absolutePath})` - ); + throw new Error(`File path must be within workspace directory (${workspaceDir}) or /tmp directory. ` + `Provided path: ${filePath} (resolved to: ${absolutePath})`); } if (!fs.existsSync(filePath)) { throw new Error(`File not found: ${filePath}`); @@ -2579,10 +2457,7 @@ jobs: }; const entryJSON = JSON.stringify(entry); fs.appendFileSync(outputFile, entryJSON + "\n"); - const outputText = - jobConfig && jobConfig.output - ? jobConfig.output - : `Safe-job '${configKey}' executed successfully with arguments: ${JSON.stringify(args)}`; + const outputText = jobConfig && jobConfig.output ? jobConfig.output : `Safe-job '${configKey}' executed successfully with arguments: ${JSON.stringify(args)}`; return { content: [ { @@ -3098,16 +2973,13 @@ jobs: return result; } function renderMarkdownTemplate(markdown) { - let result = markdown.replace( - /(\n?)([ \t]*{{#if\s+([^}]*)}}[ \t]*\n)([\s\S]*?)([ \t]*{{\/if}}[ \t]*)(\n?)/g, - (match, leadNL, openLine, cond, body, closeLine, trailNL) => { - if (isTruthy(cond)) { - return leadNL + body; - } else { - return ""; - } + let result = markdown.replace(/(\n?)([ \t]*{{#if\s+([^}]*)}}[ \t]*\n)([\s\S]*?)([ \t]*{{\/if}}[ \t]*)(\n?)/g, (match, leadNL, openLine, cond, body, closeLine, trailNL) => { + if (isTruthy(cond)) { + return leadNL + body; + } else { + return ""; } - ); + }); result = result.replace(/{{#if\s+([^}]*)}}([\s\S]*?){{\/if}}/g, (_, cond, body) => (isTruthy(cond) ? body : "")); result = result.replace(/\n{3,}/g, "\n\n"); return result; @@ -3477,36 +3349,33 @@ jobs: }); } function sanitizeUrlProtocols(s) { - return s.replace( - /((?:http|ftp|file|ssh|git):\/\/([\w.-]*)(?:[^\s]*)|(?:data|javascript|vbscript|about|mailto|tel):[^\s]+)/gi, - (match, _fullMatch, domain) => { - if (domain) { - const domainLower = domain.toLowerCase(); - const truncated = domainLower.length > 12 ? domainLower.substring(0, 12) + "..." : domainLower; + return s.replace(/((?:http|ftp|file|ssh|git):\/\/([\w.-]*)(?:[^\s]*)|(?:data|javascript|vbscript|about|mailto|tel):[^\s]+)/gi, (match, _fullMatch, domain) => { + if (domain) { + const domainLower = domain.toLowerCase(); + const truncated = domainLower.length > 12 ? domainLower.substring(0, 12) + "..." : domainLower; + if (typeof core !== "undefined" && core.info) { + core.info(`Redacted URL: ${truncated}`); + } + if (typeof core !== "undefined" && core.debug) { + core.debug(`Redacted URL (full): ${match}`); + } + addRedactedDomain(domainLower); + } else { + const protocolMatch = match.match(/^([^:]+):/); + if (protocolMatch) { + const protocol = protocolMatch[1] + ":"; + const truncated = match.length > 12 ? match.substring(0, 12) + "..." : match; if (typeof core !== "undefined" && core.info) { core.info(`Redacted URL: ${truncated}`); } if (typeof core !== "undefined" && core.debug) { core.debug(`Redacted URL (full): ${match}`); } - addRedactedDomain(domainLower); - } else { - const protocolMatch = match.match(/^([^:]+):/); - if (protocolMatch) { - const protocol = protocolMatch[1] + ":"; - const truncated = match.length > 12 ? match.substring(0, 12) + "..." : match; - if (typeof core !== "undefined" && core.info) { - core.info(`Redacted URL: ${truncated}`); - } - if (typeof core !== "undefined" && core.debug) { - core.debug(`Redacted URL (full): ${match}`); - } - addRedactedDomain(protocol); - } + addRedactedDomain(protocol); } - return "(redacted)"; } - ); + return "(redacted)"; + }); } function neutralizeCommands(s) { const commandName = process.env.GH_AW_COMMAND; @@ -3528,37 +3397,7 @@ jobs: return s.replace(//g, "").replace(//g, ""); } function convertXmlTags(s) { - const allowedTags = [ - "b", - "blockquote", - "br", - "code", - "details", - "em", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "hr", - "i", - "li", - "ol", - "p", - "pre", - "strong", - "sub", - "summary", - "sup", - "table", - "tbody", - "td", - "th", - "thead", - "tr", - "ul", - ]; + const allowedTags = ["b", "blockquote", "br", "code", "details", "em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "li", "ol", "p", "pre", "strong", "sub", "summary", "sup", "table", "tbody", "td", "th", "thead", "tr", "ul"]; s = s.replace(//g, (match, content) => { const convertedContent = content.replace(/<(\/?[A-Za-z][A-Za-z0-9]*(?:[^>]*?))>/g, "($1)"); return `(![CDATA[${convertedContent}]])`; @@ -3670,36 +3509,33 @@ jobs: return result; } function sanitizeUrlProtocols(s) { - return s.replace( - /\b((?:http|ftp|file|ssh|git):\/\/([\w.-]+)(?:[^\s]*)|(?:data|javascript|vbscript|about|mailto|tel):[^\s]+)/gi, - (match, _fullMatch, domain) => { - if (domain) { - const domainLower = domain.toLowerCase(); - const truncated = domainLower.length > 12 ? domainLower.substring(0, 12) + "..." : domainLower; + return s.replace(/\b((?:http|ftp|file|ssh|git):\/\/([\w.-]+)(?:[^\s]*)|(?:data|javascript|vbscript|about|mailto|tel):[^\s]+)/gi, (match, _fullMatch, domain) => { + if (domain) { + const domainLower = domain.toLowerCase(); + const truncated = domainLower.length > 12 ? domainLower.substring(0, 12) + "..." : domainLower; + if (typeof core !== "undefined" && core.info) { + core.info(`Redacted URL: ${truncated}`); + } + if (typeof core !== "undefined" && core.debug) { + core.debug(`Redacted URL (full): ${match}`); + } + addRedactedDomain(domainLower); + } else { + const protocolMatch = match.match(/^([^:]+):/); + if (protocolMatch) { + const protocol = protocolMatch[1] + ":"; + const truncated = match.length > 12 ? match.substring(0, 12) + "..." : match; if (typeof core !== "undefined" && core.info) { core.info(`Redacted URL: ${truncated}`); } if (typeof core !== "undefined" && core.debug) { core.debug(`Redacted URL (full): ${match}`); } - addRedactedDomain(domainLower); - } else { - const protocolMatch = match.match(/^([^:]+):/); - if (protocolMatch) { - const protocol = protocolMatch[1] + ":"; - const truncated = match.length > 12 ? match.substring(0, 12) + "..." : match; - if (typeof core !== "undefined" && core.info) { - core.info(`Redacted URL: ${truncated}`); - } - if (typeof core !== "undefined" && core.debug) { - core.debug(`Redacted URL (full): ${match}`); - } - addRedactedDomain(protocol); - } + addRedactedDomain(protocol); } - return "(redacted)"; } - ); + return "(redacted)"; + }); } function neutralizeCommands(s) { const commandName = process.env.GH_AW_COMMAND; @@ -3725,37 +3561,7 @@ jobs: return s.replace(//g, "").replace(//g, ""); } function convertXmlTags(s) { - const allowedTags = [ - "b", - "blockquote", - "br", - "code", - "details", - "em", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "hr", - "i", - "li", - "ol", - "p", - "pre", - "strong", - "sub", - "summary", - "sup", - "table", - "tbody", - "td", - "th", - "thead", - "tr", - "ul", - ]; + const allowedTags = ["b", "blockquote", "br", "code", "details", "em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "li", "ol", "p", "pre", "strong", "sub", "summary", "sup", "table", "tbody", "td", "th", "thead", "tr", "ul"]; s = s.replace(//g, (match, content) => { const convertedContent = content.replace(/<(\/?[A-Za-z][A-Za-z0-9]*(?:[^>]*?))>/g, "($1)"); return `(![CDATA[${convertedContent}]])`; @@ -4447,9 +4253,7 @@ jobs: core.info(`Loaded validation config from ${validationConfigPath}`); } } catch (error) { - core.warning( - `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}` - ); + core.warning(`Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}`); } const mentionsConfig = validationConfig?.mentions || null; const allowedMentions = await resolveAllowedMentionsFromPayload(context, github, core, mentionsConfig); @@ -4662,9 +4466,7 @@ jobs: core.info(`[INGESTION] Line ${i + 1}: Original type='${originalType}', Normalized type='${itemType}'`); item.type = itemType; if (!expectedOutputTypes[itemType]) { - core.warning( - `[INGESTION] Line ${i + 1}: Type '${itemType}' not found in expected types: ${JSON.stringify(Object.keys(expectedOutputTypes))}` - ); + core.warning(`[INGESTION] Line ${i + 1}: Type '${itemType}' not found in expected types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`); errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`); continue; } @@ -4746,10 +4548,7 @@ jobs: core.info(`Patch file ${hasPatch ? "exists" : "does not exist"} at: ${patchPath}`); let allowEmptyPR = false; if (safeOutputsConfig) { - if ( - safeOutputsConfig["create-pull-request"]?.["allow-empty"] === true || - safeOutputsConfig["create_pull_request"]?.["allow_empty"] === true - ) { + if (safeOutputsConfig["create-pull-request"]?.["allow-empty"] === true || safeOutputsConfig["create_pull_request"]?.["allow_empty"] === true) { allowEmptyPR = true; core.info(`allow-empty is enabled for create-pull-request`); } @@ -5120,24 +4919,7 @@ jobs: "Custom Agents": [], Other: [], }; - const builtinTools = [ - "bash", - "write_bash", - "read_bash", - "stop_bash", - "list_bash", - "grep", - "glob", - "view", - "create", - "edit", - "store_memory", - "code_review", - "codeql_checker", - "report_progress", - "report_intent", - "gh-advisory-database", - ]; + const builtinTools = ["bash", "write_bash", "read_bash", "stop_bash", "list_bash", "grep", "glob", "view", "create", "edit", "store_memory", "code_review", "codeql_checker", "report_progress", "report_intent", "gh-advisory-database"]; const internalTools = ["fetch_copilot_cli_documentation"]; for (const tool of initEntry.tools) { const toolLower = tool.toLowerCase(); @@ -5533,9 +5315,7 @@ jobs: const cacheCreationTokens = usage.cache_creation_input_tokens || 0; const cacheReadTokens = usage.cache_read_input_tokens || 0; const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; - lines.push( - ` Tokens: ${totalTokens.toLocaleString()} total (${usage.input_tokens.toLocaleString()} in / ${usage.output_tokens.toLocaleString()} out)` - ); + lines.push(` Tokens: ${totalTokens.toLocaleString()} total (${usage.input_tokens.toLocaleString()} in / ${usage.output_tokens.toLocaleString()} out)`); } } if (lastEntry?.total_cost_usd) { @@ -5694,9 +5474,7 @@ jobs: const cacheCreationTokens = usage.cache_creation_input_tokens || 0; const cacheReadTokens = usage.cache_read_input_tokens || 0; const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; - lines.push( - ` Tokens: ${totalTokens.toLocaleString()} total (${usage.input_tokens.toLocaleString()} in / ${usage.output_tokens.toLocaleString()} out)` - ); + lines.push(` Tokens: ${totalTokens.toLocaleString()} total (${usage.input_tokens.toLocaleString()} in / ${usage.output_tokens.toLocaleString()} out)`); } } if (lastEntry?.total_cost_usd) { @@ -5797,11 +5575,7 @@ jobs: }); } function extractPremiumRequestCount(logContent) { - const patterns = [ - /premium\s+requests?\s+consumed:?\s*(\d+)/i, - /(\d+)\s+premium\s+requests?\s+consumed/i, - /consumed\s+(\d+)\s+premium\s+requests?/i, - ]; + const patterns = [/premium\s+requests?\s+consumed:?\s*(\d+)/i, /(\d+)\s+premium\s+requests?\s+consumed/i, /consumed\s+(\d+)\s+premium\s+requests?/i]; for (const pattern of patterns) { const match = logContent.match(pattern); if (match && match[1]) { @@ -5873,8 +5647,7 @@ jobs: const initEntry = logEntries.find(entry => entry.type === "system" && entry.subtype === "init"); markdown += generateInformationSection(lastEntry, { additionalInfoCallback: entry => { - const isPremiumModel = - initEntry && initEntry.model_info && initEntry.model_info.billing && initEntry.model_info.billing.is_premium === true; + const isPremiumModel = initEntry && initEntry.model_info && initEntry.model_info.billing && initEntry.model_info.billing.is_premium === true; if (isPremiumModel) { const premiumRequestCount = extractPremiumRequestCount(logContent); return `**Premium Requests Consumed:** ${premiumRequestCount}\n\n`; @@ -6301,299 +6074,151 @@ jobs: with: script: | function sanitizeWorkflowName(name) { - return name - .toLowerCase() - .replace(/[:\\/\s]/g, "-") - .replace(/[^a-z0-9._-]/g, "-"); - } - function main() { - const fs = require("fs"); - const path = require("path"); - try { - const squidLogsDir = `/tmp/gh-aw/sandbox/firewall/logs/`; - if (!fs.existsSync(squidLogsDir)) { - core.info(`No firewall logs directory found at: ${squidLogsDir}`); - return; - } - const files = fs.readdirSync(squidLogsDir).filter(file => file.endsWith(".log")); - if (files.length === 0) { - core.info(`No firewall log files found in: ${squidLogsDir}`); - return; - } - core.info(`Found ${files.length} firewall log file(s)`); - let totalRequests = 0; - let allowedRequests = 0; - let deniedRequests = 0; - const allowedDomains = new Set(); - const deniedDomains = new Set(); - const requestsByDomain = new Map(); - for (const file of files) { - const filePath = path.join(squidLogsDir, file); - core.info(`Parsing firewall log: ${file}`); - const content = fs.readFileSync(filePath, "utf8"); - const lines = content.split("\n").filter(line => line.trim()); - for (const line of lines) { - const entry = parseFirewallLogLine(line); - if (!entry) { - continue; - } - totalRequests++; - const isAllowed = isRequestAllowed(entry.decision, entry.status); - if (isAllowed) { - allowedRequests++; - allowedDomains.add(entry.domain); - } else { - deniedRequests++; - deniedDomains.add(entry.domain); - } - if (!requestsByDomain.has(entry.domain)) { - requestsByDomain.set(entry.domain, { allowed: 0, denied: 0 }); - } - const domainStats = requestsByDomain.get(entry.domain); - if (isAllowed) { - domainStats.allowed++; - } else { - domainStats.denied++; - } - } - } - const summary = generateFirewallSummary({ - totalRequests, - allowedRequests, - deniedRequests, - allowedDomains: Array.from(allowedDomains).sort(), - deniedDomains: Array.from(deniedDomains).sort(), - requestsByDomain, - }); - core.summary.addRaw(summary).write(); - core.info("Firewall log summary generated successfully"); - } catch (error) { - core.setFailed(error instanceof Error ? error : String(error)); - } - } - function parseFirewallLogLine(line) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) { - return null; - } - const fields = trimmed.match(/(?:[^\s"]+|"[^"]*")+/g); - if (!fields || fields.length < 10) { - return null; - } - const timestamp = fields[0]; - if (!/^\d+(\.\d+)?$/.test(timestamp)) { - return null; - } - return { - timestamp, - clientIpPort: fields[1], - domain: fields[2], - destIpPort: fields[3], - proto: fields[4], - method: fields[5], - status: fields[6], - decision: fields[7], - url: fields[8], - userAgent: fields[9]?.replace(/^"|"$/g, "") || "-", - }; - } - function isRequestAllowed(decision, status) { - const statusCode = parseInt(status, 10); - if (statusCode === 200 || statusCode === 206 || statusCode === 304) { - return true; - } - if (decision.includes("TCP_TUNNEL") || decision.includes("TCP_HIT") || decision.includes("TCP_MISS")) { - return true; - } - if (decision.includes("NONE_NONE") || decision.includes("TCP_DENIED") || statusCode === 403 || statusCode === 407) { - return false; - } - return false; - } - function generateFirewallSummary(analysis) { - const { totalRequests, requestsByDomain } = analysis; - const validDomains = Array.from(requestsByDomain.keys()) - .filter(domain => domain !== "-") - .sort(); - const uniqueDomainCount = validDomains.length; - let validAllowedRequests = 0; - let validDeniedRequests = 0; - for (const domain of validDomains) { - const stats = requestsByDomain.get(domain); - validAllowedRequests += stats.allowed; - validDeniedRequests += stats.denied; - } - let summary = "### 🔥 Firewall Activity\n\n"; - summary += "
\n"; - summary += `📊 ${totalRequests} request${totalRequests !== 1 ? "s" : ""} | `; - summary += `${validAllowedRequests} allowed | `; - summary += `${validDeniedRequests} blocked | `; - summary += `${uniqueDomainCount} unique domain${uniqueDomainCount !== 1 ? "s" : ""}\n\n`; - if (uniqueDomainCount > 0) { - summary += "| Domain | Allowed | Denied |\n"; - summary += "|--------|---------|--------|\n"; - for (const domain of validDomains) { - const stats = requestsByDomain.get(domain); - summary += `| ${domain} | ${stats.allowed} | ${stats.denied} |\n`; - } - } else { - summary += "No firewall activity detected.\n"; - } - summary += "\n
\n\n"; - return summary; - } - - const isDirectExecution = - - typeof module === "undefined" || (typeof require !== "undefined" && typeof require.main !== "undefined" && require.main === module); - + const isDirectExecution = typeof module === "undefined" || (typeof require !== "undefined" && typeof require.main !== "undefined" && require.main === module); if (isDirectExecution) { - main(); - } - - name: Upload Agent Stdio if: always() uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 @@ -6748,9 +6373,7 @@ jobs: } lastIndex = regex.lastIndex; if (iterationCount === ITERATION_WARNING_THRESHOLD) { - core.warning( - `High iteration count (${iterationCount}) on line ${lineIndex + 1} with pattern: ${pattern.description || pattern.pattern}` - ); + core.warning(`High iteration count (${iterationCount}) on line ${lineIndex + 1} with pattern: ${pattern.description || pattern.pattern}`); core.warning(`Line content (truncated): ${truncateString(line, 200)}`); } if (iterationCount > MAX_ITERATIONS_PER_LINE) { @@ -7057,9 +6680,7 @@ jobs: core.setOutput("total_count", missingTools.length.toString()); if (missingTools.length > 0) { core.info("Missing tools summary:"); - core.summary - .addHeading("Missing Tools Report", 3) - .addRaw(`Found **${missingTools.length}** missing tool${missingTools.length > 1 ? "s" : ""} in this workflow execution.\n\n`); + core.summary.addHeading("Missing Tools Report", 3).addRaw(`Found **${missingTools.length}** missing tool${missingTools.length > 1 ? "s" : ""} in this workflow execution.\n\n`); missingTools.forEach((tool, index) => { core.info(`${index + 1}. Tool: ${tool.tool}`); core.info(` Reason: ${tool.reason}`); @@ -7192,9 +6813,7 @@ jobs: const messages = getMessages(); const templateContext = toSnakeCase(ctx); const defaultMessage = "⚠️ Security scanning failed for [{workflow_name}]({run_url}). Review the logs for details."; - return messages?.detectionFailure - ? renderTemplate(messages.detectionFailure, templateContext) - : renderTemplate(defaultMessage, templateContext); + return messages?.detectionFailure ? renderTemplate(messages.detectionFailure, templateContext) : renderTemplate(defaultMessage, templateContext); } function collectGeneratedAssets() { const assets = []; @@ -7722,29 +7341,21 @@ jobs: } function parseProjectInput(projectUrl) { if (!projectUrl || typeof projectUrl !== "string") { - throw new Error( - `Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.` - ); + throw new Error(`Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`); } const urlMatch = projectUrl.match(/github\.com\/(?:users|orgs)\/[^/]+\/projects\/(\d+)/); if (!urlMatch) { - throw new Error( - `Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).` - ); + throw new Error(`Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`); } return urlMatch[1]; } function parseProjectUrl(projectUrl) { if (!projectUrl || typeof projectUrl !== "string") { - throw new Error( - `Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.` - ); + throw new Error(`Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`); } const match = projectUrl.match(/github\.com\/(users|orgs)\/([^/]+)\/projects\/(\d+)/); if (!match) { - throw new Error( - `Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).` - ); + throw new Error(`Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`); } return { scope: match[1], ownerLogin: match[2], projectNumber: match[3] }; } @@ -7955,9 +7566,7 @@ jobs: } catch (viewerError) { core.warning(`Could not resolve token identity (viewer.login): ${viewerError.message}`); } - core.info( - `[2/5] Resolving project from URL (scope=${projectInfo.scope}, login=${projectInfo.ownerLogin}, number=${projectNumberFromUrl})...` - ); + core.info(`[2/5] Resolving project from URL (scope=${projectInfo.scope}, login=${projectInfo.ownerLogin}, number=${projectNumberFromUrl})...`); let projectId; let resolvedProjectNumber = projectNumberFromUrl; try { @@ -8018,12 +7627,7 @@ jobs: let contentNumber = null; if (hasContentNumber || hasIssue || hasPullRequest) { const rawContentNumber = hasContentNumber ? output.content_number : hasIssue ? output.issue : output.pull_request; - const sanitizedContentNumber = - rawContentNumber === undefined || rawContentNumber === null - ? "" - : typeof rawContentNumber === "number" - ? rawContentNumber.toString() - : String(rawContentNumber).trim(); + const sanitizedContentNumber = rawContentNumber === undefined || rawContentNumber === null ? "" : typeof rawContentNumber === "number" ? rawContentNumber.toString() : String(rawContentNumber).trim(); if (!sanitizedContentNumber) { core.warning("Content number field provided but empty; skipping project item update."); } else if (!/^\d+$/.test(sanitizedContentNumber)) { @@ -8033,14 +7637,7 @@ jobs: } } if (contentNumber !== null) { - const contentType = - output.content_type === "pull_request" - ? "PullRequest" - : output.content_type === "issue" - ? "Issue" - : output.issue - ? "Issue" - : "PullRequest"; + const contentType = output.content_type === "pull_request" ? "PullRequest" : output.content_type === "issue" ? "Issue" : output.issue ? "Issue" : "PullRequest"; const contentQuery = contentType === "Issue" ? `query($owner: String!, $repo: String!, $number: Int!) { @@ -8169,8 +7766,7 @@ jobs: .join(" "); let field = projectFields.find(f => f.name.toLowerCase() === normalizedFieldName.toLowerCase()); if (!field) { - const isTextField = - fieldName.toLowerCase() === "classification" || (typeof fieldValue === "string" && fieldValue.includes("|")); + const isTextField = fieldName.toLowerCase() === "classification" || (typeof fieldValue === "string" && fieldValue.includes("|")); if (isTextField) { try { const createFieldResult = await github.graphql( @@ -8246,10 +7842,7 @@ jobs: let option = field.options.find(o => o.name === fieldValue); if (!option) { try { - const allOptions = [ - ...field.options.map(o => ({ name: o.name, description: "", color: o.color || "GRAY" })), - { name: String(fieldValue), description: "", color: "GRAY" }, - ]; + const allOptions = [...field.options.map(o => ({ name: o.name, description: "", color: o.color || "GRAY" })), { name: String(fieldValue), description: "", color: "GRAY" }]; const createOptionResult = await github.graphql( `mutation($fieldId: ID!, $fieldName: String!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) { updateProjectV2Field(input: { From c45709b541aed225fa18236ebb793eab9752bf0f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 07:17:32 +0000 Subject: [PATCH 3/5] Apply terser optimization to JavaScript files - reduce 55k+ lines Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/js/add_comment.test.cjs | 1702 +++------- pkg/workflow/js/add_copilot_reviewer.cjs | 66 +- pkg/workflow/js/add_copilot_reviewer.test.cjs | 211 +- pkg/workflow/js/add_labels.test.cjs | 1514 +++------ .../js/add_reaction_and_edit_comment.test.cjs | 886 ++--- pkg/workflow/js/add_reviewer.test.cjs | 384 +-- pkg/workflow/js/assign_agent_helpers.cjs | 472 +-- .../js/assign_copilot_to_created_issues.cjs | 135 +- pkg/workflow/js/assign_issue.cjs | 109 +- pkg/workflow/js/assign_issue.test.cjs | 503 +-- pkg/workflow/js/assign_milestone.test.cjs | 402 +-- pkg/workflow/js/assign_to_agent.cjs | 236 +- .../js/check_command_position.test.cjs | 332 +- pkg/workflow/js/check_membership.test.cjs | 443 +-- pkg/workflow/js/check_permissions.test.cjs | 457 +-- pkg/workflow/js/check_permissions_utils.cjs | 109 +- .../js/check_permissions_utils.test.cjs | 326 +- pkg/workflow/js/check_skip_if_match.test.cjs | 495 +-- pkg/workflow/js/check_stop_time.test.cjs | 267 +- pkg/workflow/js/check_team_member.test.cjs | 385 +-- pkg/workflow/js/check_workflow_timestamp.cjs | 108 +- .../js/check_workflow_timestamp.test.cjs | 522 ++- .../js/check_workflow_timestamp_api.cjs | 104 +- pkg/workflow/js/checkout_pr_branch.cjs | 56 +- pkg/workflow/js/checkout_pr_branch.test.cjs | 364 +-- pkg/workflow/js/close_discussion.test.cjs | 509 +-- pkg/workflow/js/close_entity_helpers.cjs | 390 +-- pkg/workflow/js/close_entity_helpers.test.cjs | 472 ++- pkg/workflow/js/close_issue.test.cjs | 548 +--- pkg/workflow/js/close_older_discussions.cjs | 277 +- .../js/close_older_discussions.test.cjs | 842 ++--- .../js/collect_ndjson_output.test.cjs | 2904 ----------------- pkg/workflow/js/compute_text.test.cjs | 809 +---- pkg/workflow/js/create_agent_task.cjs | 180 +- pkg/workflow/js/create_agent_task.test.cjs | 259 +- .../js/create_code_scanning_alert.test.cjs | 545 +--- pkg/workflow/js/create_discussion.test.cjs | 483 +-- pkg/workflow/js/create_issue.cjs | 353 +- pkg/workflow/js/create_issue.test.cjs | 989 +----- .../js/create_pr_review_comment.test.cjs | 738 +---- pkg/workflow/js/create_pull_request.test.cjs | 1736 +--------- pkg/workflow/js/estimate_tokens.cjs | 17 +- pkg/workflow/js/estimate_tokens.test.cjs | 53 +- pkg/workflow/js/expiration_helpers.cjs | 28 +- pkg/workflow/js/fuzz_mentions_harness.cjs | 52 +- .../fuzz_sanitize_incoming_text_harness.cjs | 51 +- .../js/fuzz_sanitize_label_harness.cjs | 50 +- .../js/fuzz_sanitize_output_harness.cjs | 52 +- pkg/workflow/js/generate_compact_schema.cjs | 44 +- .../js/generate_compact_schema.test.cjs | 87 +- pkg/workflow/js/generate_footer.cjs | 95 +- pkg/workflow/js/generate_footer.test.cjs | 229 +- pkg/workflow/js/generate_git_patch.cjs | 142 +- pkg/workflow/js/generate_git_patch.test.cjs | 130 +- .../js/generate_safe_inputs_config.cjs | 35 +- .../js/generate_safe_inputs_config.test.cjs | 77 +- pkg/workflow/js/get_base_branch.cjs | 15 +- pkg/workflow/js/get_base_branch.test.cjs | 55 +- pkg/workflow/js/get_current_branch.cjs | 45 +- pkg/workflow/js/get_current_branch.test.cjs | 97 +- pkg/workflow/js/get_repository_url.cjs | 30 +- pkg/workflow/js/get_repository_url.test.cjs | 119 +- pkg/workflow/js/get_tracker_id.cjs | 21 +- pkg/workflow/js/get_tracker_id.test.cjs | 103 +- pkg/workflow/js/hide_comment.test.cjs | 403 +-- pkg/workflow/js/interpolate_prompt.cjs | 127 +- pkg/workflow/js/interpolate_prompt.test.cjs | 329 +- .../js/interpolate_prompt_additional.test.cjs | 431 +-- pkg/workflow/js/is_truthy.cjs | 13 +- pkg/workflow/js/is_truthy.test.cjs | 61 +- pkg/workflow/js/link_sub_issue.test.cjs | 356 +- pkg/workflow/js/load_agent_output.cjs | 91 +- pkg/workflow/js/load_agent_output.test.cjs | 249 +- pkg/workflow/js/lock-issue.test.cjs | 241 +- pkg/workflow/js/log_parser_bootstrap.cjs | 140 +- pkg/workflow/js/log_parser_bootstrap.test.cjs | 325 +- pkg/workflow/js/log_parser_shared.cjs | 1401 +------- pkg/workflow/js/log_parser_shared.test.cjs | 1825 +---------- pkg/workflow/js/mcp_handler_python.cjs | 101 +- pkg/workflow/js/mcp_handler_shell.cjs | 147 +- pkg/workflow/js/mcp_http_transport.cjs | 296 +- pkg/workflow/js/mcp_http_transport.test.cjs | 254 +- pkg/workflow/js/mcp_logger.cjs | 54 +- pkg/workflow/js/mcp_logger.test.cjs | 107 +- pkg/workflow/js/mcp_server_core.cjs | 748 +---- pkg/workflow/js/mcp_server_core.test.cjs | 901 +---- pkg/workflow/js/messages.cjs | 59 +- pkg/workflow/js/messages.test.cjs | 596 +--- pkg/workflow/js/messages_close_discussion.cjs | 46 +- pkg/workflow/js/messages_core.cjs | 92 +- pkg/workflow/js/messages_footer.cjs | 172 +- pkg/workflow/js/messages_run_status.cjs | 117 +- pkg/workflow/js/messages_staged.cjs | 58 +- pkg/workflow/js/missing_tool.cjs | 139 +- pkg/workflow/js/missing_tool.test.cjs | 356 +- pkg/workflow/js/noop.test.cjs | 195 +- pkg/workflow/js/normalize_branch_name.cjs | 55 +- .../js/normalize_branch_name.test.cjs | 83 +- pkg/workflow/js/notify_comment_error.cjs | 213 +- pkg/workflow/js/notify_comment_error.test.cjs | 458 +-- pkg/workflow/js/package-lock.json | 479 ++- pkg/workflow/js/package.json | 4 +- pkg/workflow/js/parse_claude_log.cjs | 125 +- pkg/workflow/js/parse_claude_log.test.cjs | 1014 +----- pkg/workflow/js/parse_codex_log.cjs | 466 +-- pkg/workflow/js/parse_codex_log.test.cjs | 494 +-- pkg/workflow/js/parse_copilot_log.cjs | 694 +--- pkg/workflow/js/parse_copilot_log.test.cjs | 1250 +------ pkg/workflow/js/parse_firewall_logs.cjs | 227 +- pkg/workflow/js/parse_firewall_logs.test.cjs | 257 +- pkg/workflow/js/push_repo_memory.cjs | 215 +- .../js/push_to_pull_request_branch.test.cjs | 1189 +------ pkg/workflow/js/read_buffer.cjs | 68 +- pkg/workflow/js/read_buffer.test.cjs | 191 +- pkg/workflow/js/redact_secrets.test.cjs | 247 +- pkg/workflow/js/remove_duplicate_title.cjs | 51 +- .../js/remove_duplicate_title.test.cjs | 361 +- pkg/workflow/js/render_template.cjs | 88 +- pkg/workflow/js/render_template.test.cjs | 339 +- pkg/workflow/js/repo_helpers.cjs | 81 +- pkg/workflow/js/repo_helpers.test.cjs | 143 +- pkg/workflow/js/resolve_mentions.cjs | 195 +- pkg/workflow/js/resolve_mentions.test.cjs | 292 +- .../js/resolve_mentions_from_payload.cjs | 199 +- pkg/workflow/js/runtime_import.cjs | 149 +- pkg/workflow/js/runtime_import.test.cjs | 436 +-- pkg/workflow/js/safe_inputs_bootstrap.cjs | 81 +- .../js/safe_inputs_bootstrap.test.cjs | 169 +- pkg/workflow/js/safe_inputs_config_loader.cjs | 54 +- .../js/safe_inputs_config_loader.test.cjs | 134 +- pkg/workflow/js/safe_inputs_mcp_server.cjs | 114 +- .../js/safe_inputs_mcp_server.test.cjs | 849 +---- .../js/safe_inputs_mcp_server_http.cjs | 341 +- .../js/safe_inputs_mcp_server_http.test.cjs | 357 +- pkg/workflow/js/safe_inputs_tool_factory.cjs | 38 +- .../js/safe_inputs_tool_factory.test.cjs | 71 +- pkg/workflow/js/safe_inputs_validation.cjs | 33 +- .../js/safe_inputs_validation.test.cjs | 139 +- pkg/workflow/js/safe_output_helpers.cjs | 171 +- pkg/workflow/js/safe_output_helpers.test.cjs | 354 +- pkg/workflow/js/safe_output_processor.cjs | 257 +- .../js/safe_output_processor.test.cjs | 386 +-- .../js/safe_output_type_validator.cjs | 569 +--- .../js/safe_output_type_validator.test.cjs | 469 +-- pkg/workflow/js/safe_output_validator.cjs | 165 +- .../js/safe_output_validator.test.cjs | 284 +- pkg/workflow/js/safe_outputs_append.cjs | 36 +- pkg/workflow/js/safe_outputs_append.test.cjs | 246 +- pkg/workflow/js/safe_outputs_bootstrap.cjs | 75 +- .../js/safe_outputs_bootstrap.test.cjs | 154 +- .../js/safe_outputs_branch_detection.test.cjs | 106 +- pkg/workflow/js/safe_outputs_config.cjs | 60 +- pkg/workflow/js/safe_outputs_config.test.cjs | 168 +- pkg/workflow/js/safe_outputs_handlers.cjs | 323 +- .../js/safe_outputs_handlers.test.cjs | 267 +- pkg/workflow/js/safe_outputs_mcp_client.cjs | 136 +- .../js/safe_outputs_mcp_client.test.cjs | 202 +- .../safe_outputs_mcp_large_content.test.cjs | 539 +-- pkg/workflow/js/safe_outputs_mcp_server.cjs | 81 +- .../js/safe_outputs_mcp_server.test.cjs | 403 +-- .../safe_outputs_mcp_server_defaults.test.cjs | 783 +---- .../js/safe_outputs_mcp_server_patch.test.cjs | 218 +- pkg/workflow/js/safe_outputs_tools_loader.cjs | 166 +- .../js/safe_outputs_tools_loader.test.cjs | 322 +- .../js/safe_outputs_type_validation.test.cjs | 113 +- pkg/workflow/js/sanitize_content.cjs | 289 +- pkg/workflow/js/sanitize_content.test.cjs | 707 +--- pkg/workflow/js/sanitize_content_core.cjs | 397 +-- pkg/workflow/js/sanitize_content_old.cjs | 405 +-- pkg/workflow/js/sanitize_incoming_text.cjs | 28 +- pkg/workflow/js/sanitize_label_content.cjs | 30 +- .../js/sanitize_label_content.test.cjs | 143 +- pkg/workflow/js/sanitize_output.test.cjs | 1099 +------ pkg/workflow/js/sanitize_workflow_name.cjs | 15 +- .../js/sanitize_workflow_name.test.cjs | 47 +- pkg/workflow/js/staged_preview.cjs | 36 +- pkg/workflow/js/substitute_placeholders.cjs | 51 +- .../js/substitute_placeholders.test.cjs | 134 +- pkg/workflow/js/temporary_id.cjs | 182 +- pkg/workflow/js/temporary_id.test.cjs | 283 +- pkg/workflow/js/unlock-issue.test.cjs | 249 +- pkg/workflow/js/update_activation_comment.cjs | 156 +- .../js/update_activation_comment.test.cjs | 438 +-- pkg/workflow/js/update_context_helpers.cjs | 70 +- .../js/update_context_helpers.test.cjs | 183 +- pkg/workflow/js/update_issue.test.cjs | 328 +- .../js/update_pr_description_helpers.cjs | 130 +- pkg/workflow/js/update_project.cjs | 822 +---- pkg/workflow/js/update_project.test.cjs | 491 +-- pkg/workflow/js/update_pull_request.test.cjs | 737 +---- pkg/workflow/js/update_release.test.cjs | 398 +-- pkg/workflow/js/update_runner.cjs | 352 +- pkg/workflow/js/update_runner.test.cjs | 609 +--- pkg/workflow/js/upload_assets.test.cjs | 434 +-- pkg/workflow/js/validate_errors.cjs | 354 +- .../js/write_large_content_to_file.cjs | 45 +- .../js/write_large_content_to_file.test.cjs | 118 +- 197 files changed, 4440 insertions(+), 59495 deletions(-) diff --git a/pkg/workflow/js/add_comment.test.cjs b/pkg/workflow/js/add_comment.test.cjs index ef7867c95d7..c432f9f6513 100644 --- a/pkg/workflow/js/add_comment.test.cjs +++ b/pkg/workflow/js/add_comment.test.cjs @@ -1,1262 +1,452 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import fs from "fs"; import path from "path"; - -// Mock the global objects that GitHub Actions provides const mockCore = { - // Core logging functions - debug: vi.fn(), - info: vi.fn(), - notice: vi.fn(), - warning: vi.fn(), - error: vi.fn(), - - // Core workflow functions - setFailed: vi.fn(), - setOutput: vi.fn(), - exportVariable: vi.fn(), - setSecret: vi.fn(), - - // Input/state functions (less commonly used but included for completeness) - getInput: vi.fn(), - getBooleanInput: vi.fn(), - getMultilineInput: vi.fn(), - getState: vi.fn(), - saveState: vi.fn(), - - // Group functions - startGroup: vi.fn(), - endGroup: vi.fn(), - group: vi.fn(), - - // Other utility functions - addPath: vi.fn(), - setCommandEcho: vi.fn(), - isDebug: vi.fn().mockReturnValue(false), - getIDToken: vi.fn(), - toPlatformPath: vi.fn(), - toPosixPath: vi.fn(), - toWin32Path: vi.fn(), - - // Summary object with chainable methods - summary: { - addRaw: vi.fn().mockReturnThis(), - write: vi.fn().mockResolvedValue(), + debug: vi.fn(), + info: vi.fn(), + notice: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), + setOutput: vi.fn(), + exportVariable: vi.fn(), + setSecret: vi.fn(), + getInput: vi.fn(), + getBooleanInput: vi.fn(), + getMultilineInput: vi.fn(), + getState: vi.fn(), + saveState: vi.fn(), + startGroup: vi.fn(), + endGroup: vi.fn(), + group: vi.fn(), + addPath: vi.fn(), + setCommandEcho: vi.fn(), + isDebug: vi.fn().mockReturnValue(!1), + getIDToken: vi.fn(), + toPlatformPath: vi.fn(), + toPosixPath: vi.fn(), + toWin32Path: vi.fn(), + summary: { addRaw: vi.fn().mockReturnThis(), write: vi.fn().mockResolvedValue() }, }, -}; - -const mockGithub = { - rest: { - issues: { - createComment: vi.fn(), - }, - }, -}; - -const mockContext = { - eventName: "issues", - runId: 12345, - repo: { - owner: "testowner", - repo: "testrepo", - }, - payload: { - issue: { - number: 123, - }, - repository: { - html_url: "https://github.com/testowner/testrepo", - }, - }, -}; - -// Set up global variables -global.core = mockCore; -global.github = mockGithub; -global.context = mockContext; - -describe("add_comment.cjs", () => { - let createCommentScript; - - let tempFilePath; - - // Helper function to set agent output via file - const setAgentOutput = data => { - tempFilePath = path.join("/tmp", `test_agent_output_${Date.now()}_${Math.random().toString(36).slice(2)}.json`); - const content = typeof data === "string" ? data : JSON.stringify(data); - fs.writeFileSync(tempFilePath, content); - process.env.GH_AW_AGENT_OUTPUT = tempFilePath; - }; - - beforeEach(() => { - // Reset all mocks - vi.clearAllMocks(); - - // Reset environment variables - delete process.env.GH_AW_AGENT_OUTPUT; - delete process.env.GITHUB_WORKFLOW; - - // Reset context to default state - global.context.eventName = "issues"; - global.context.payload.issue = { number: 123 }; - - // Read the script content - const scriptPath = path.join(process.cwd(), "add_comment.cjs"); - createCommentScript = fs.readFileSync(scriptPath, "utf8"); - }); - - afterEach(() => { - // Clean up temporary file - if (tempFilePath && require("fs").existsSync(tempFilePath)) { - require("fs").unlinkSync(tempFilePath); - tempFilePath = undefined; - } - }); - - it("should skip when no agent output is provided", async () => { - // Remove the output content environment variable - delete process.env.GH_AW_AGENT_OUTPUT; - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - expect(mockCore.info).toHaveBeenCalledWith("No GH_AW_AGENT_OUTPUT environment variable found"); - expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(); - }); - - it("should skip when agent output is empty", async () => { - setAgentOutput(""); - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - expect(mockCore.info).toHaveBeenCalledWith("Agent output content is empty"); - expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(); - }); - - it("should skip when not in issue or PR context", async () => { - setAgentOutput({ - items: [ - { - type: "add_comment", - body: "Test comment content", - }, - ], - }); - global.context.eventName = "push"; // Not an issue or PR event - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - expect(mockCore.info).toHaveBeenCalledWith('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'); - expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(); - }); - - it("should create comment on issue successfully", async () => { - setAgentOutput({ - items: [ - { - type: "add_comment", - body: "Test comment content", - }, - ], - }); - global.context.eventName = "issues"; - - const mockComment = { - id: 456, - html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456", - }; - - mockGithub.rest.issues.createComment.mockResolvedValue({ - data: mockComment, - }); - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({ - owner: "testowner", - repo: "testrepo", - issue_number: 123, - body: expect.stringContaining("Test comment content"), - }); - - expect(mockCore.setOutput).toHaveBeenCalledWith("comment_id", 456); - expect(mockCore.setOutput).toHaveBeenCalledWith("comment_url", mockComment.html_url); - expect(mockCore.summary.addRaw).toHaveBeenCalled(); - expect(mockCore.summary.write).toHaveBeenCalled(); - }); - - it("should create comment on pull request successfully", async () => { - setAgentOutput({ - items: [ - { - type: "add_comment", - body: "Test PR comment content", - }, - ], - }); - global.context.eventName = "pull_request"; - global.context.payload.pull_request = { number: 789 }; - delete global.context.payload.issue; // Remove issue from payload - - const mockComment = { - id: 789, - html_url: "https://github.com/testowner/testrepo/issues/789#issuecomment-789", - }; - - mockGithub.rest.issues.createComment.mockResolvedValue({ - data: mockComment, - }); - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({ - owner: "testowner", - repo: "testrepo", - issue_number: 789, - body: expect.stringContaining("Test PR comment content"), - }); - }); - - it("should include run information in comment body", async () => { - setAgentOutput({ - items: [ - { - type: "add_comment", - body: "Test content", - }, - ], - }); - global.context.eventName = "issues"; - global.context.payload.issue = { number: 123 }; // Make sure issue context is properly set - - const mockComment = { - id: 456, - html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456", - }; - - mockGithub.rest.issues.createComment.mockResolvedValue({ - data: mockComment, - }); - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(); - expect(mockGithub.rest.issues.createComment.mock.calls).toHaveLength(1); - - const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0]; - expect(callArgs.body).toContain("Test content"); - // Now uses messages.cjs with pirate-themed default footer - expect(callArgs.body).toContain("This treasure was crafted by"); - expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345"); - }); - - it("should include workflow source in footer when GH_AW_WORKFLOW_SOURCE is provided", async () => { - setAgentOutput({ - items: [ - { - type: "add_comment", - body: "Test content with source", - }, - ], - }); - process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"; - process.env.GH_AW_WORKFLOW_SOURCE = "githubnext/agentics/workflows/ci-doctor.md@v1.0.0"; - process.env.GH_AW_WORKFLOW_SOURCE_URL = "https://github.com/githubnext/agentics/tree/v1.0.0/workflows/ci-doctor.md"; - global.context.eventName = "issues"; - global.context.payload.issue = { number: 123 }; - - const mockComment = { - id: 456, - html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456", - }; - - mockGithub.rest.issues.createComment.mockResolvedValue({ - data: mockComment, - }); - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(); - const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0]; - - // Check that the footer contains the expected elements (now using messages.cjs with pirate-themed footer) - expect(callArgs.body).toContain("Test content with source"); - expect(callArgs.body).toContain("[🏴‍☠️ Test Workflow]"); - expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345"); - expect(callArgs.body).toContain("gh aw add githubnext/agentics/workflows/ci-doctor.md@v1.0.0"); - }); - - it("should not include workflow source footer when GH_AW_WORKFLOW_SOURCE is not provided", async () => { - setAgentOutput({ - items: [ - { - type: "add_comment", - body: "Test content without source", - }, - ], - }); - process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"; - delete process.env.GH_AW_WORKFLOW_SOURCE; // Ensure it's not set - global.context.eventName = "issues"; - global.context.payload.issue = { number: 123 }; - - const mockComment = { - id: 456, - html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456", - }; - - mockGithub.rest.issues.createComment.mockResolvedValue({ - data: mockComment, - }); - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(); - const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0]; - - // Check that the footer does NOT contain the workflow source (now using messages.cjs with pirate-themed footer) - expect(callArgs.body).toContain("Test content without source"); - expect(callArgs.body).toContain("[🏴‍☠️ Test Workflow]"); - expect(callArgs.body).not.toContain("gh aw add"); - }); - - it("should use GITHUB_SERVER_URL when repository context is not available", async () => { - setAgentOutput({ - items: [ - { - type: "add_comment", - body: "Test content with custom server", - }, - ], - }); - process.env.GITHUB_SERVER_URL = "https://github.enterprise.com"; - global.context.eventName = "issues"; - global.context.payload.issue = { number: 123 }; - // Remove repository context to force use of GITHUB_SERVER_URL - delete global.context.payload.repository; - - const mockComment = { - id: 456, - html_url: "https://github.enterprise.com/testowner/testrepo/issues/123#issuecomment-456", - }; - - mockGithub.rest.issues.createComment.mockResolvedValue({ - data: mockComment, - }); - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(); - const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0]; - - // Check that the footer uses the custom GitHub server URL - expect(callArgs.body).toContain("Test content with custom server"); - expect(callArgs.body).toContain("https://github.enterprise.com/testowner/testrepo/actions/runs/12345"); - expect(callArgs.body).not.toContain("https://github.com/testowner/testrepo/actions/runs/12345"); - - // Clean up - delete process.env.GITHUB_SERVER_URL; - }); - - it("should fallback to https://github.com when GITHUB_SERVER_URL is not set and repository context is missing", async () => { - setAgentOutput({ - items: [ - { - type: "add_comment", - body: "Test content with fallback", - }, - ], - }); - delete process.env.GITHUB_SERVER_URL; - global.context.eventName = "issues"; - global.context.payload.issue = { number: 123 }; - // Remove repository context to test fallback - delete global.context.payload.repository; - - const mockComment = { - id: 456, - html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456", - }; - - mockGithub.rest.issues.createComment.mockResolvedValue({ - data: mockComment, - }); - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(); - const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0]; - - // Check that the footer uses the default https://github.com - expect(callArgs.body).toContain("Test content with fallback"); - expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345"); - }); - - it("should include triggering issue number in footer when in issue context", async () => { - setAgentOutput({ - items: [ - { - type: "add_comment", - body: "Comment from issue context", - }, - ], - }); - process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"; - - // Simulate issue context - global.context.eventName = "issues"; - global.context.payload.issue = { number: 42 }; - - const mockComment = { - id: 789, - html_url: "https://github.com/testowner/testrepo/issues/42#issuecomment-789", - }; - - mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }); - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0]; - - // Check that the footer includes reference to triggering issue (now using messages.cjs with pirate-themed footer) - expect(callArgs.body).toContain("Comment from issue context"); - expect(callArgs.body).toContain("[🏴‍☠️ Test Workflow]"); - expect(callArgs.body).toContain("#42"); - }); - - it("should include triggering PR number in footer when in PR context", async () => { - setAgentOutput({ - items: [ - { - type: "add_comment", - body: "Comment from PR context", - }, - ], - }); - process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"; - - // Simulate PR context - global.context.eventName = "pull_request"; - delete global.context.payload.issue; - global.context.payload.pull_request = { number: 123 }; - - const mockComment = { - id: 890, - html_url: "https://github.com/testowner/testrepo/pull/123#issuecomment-890", - }; - - mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }); - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0]; - - // Check that the footer includes reference to triggering PR (now using messages.cjs with pirate-themed footer) - expect(callArgs.body).toContain("Comment from PR context"); - expect(callArgs.body).toContain("[🏴‍☠️ Test Workflow]"); - expect(callArgs.body).toContain("#123"); - - // Clean up - delete global.context.payload.pull_request; - }); - - it("should use header level 4 for related items in comments", async () => { - setAgentOutput({ - items: [ - { - type: "add_comment", - body: "Test comment with related items", - }, - ], - }); - global.context.eventName = "issues"; - global.context.payload.issue = { number: 123 }; - - // Set environment variables for created items - process.env.GH_AW_CREATED_ISSUE_URL = "https://github.com/testowner/testrepo/issues/456"; - process.env.GH_AW_CREATED_ISSUE_NUMBER = "456"; - process.env.GH_AW_CREATED_DISCUSSION_URL = "https://github.com/testowner/testrepo/discussions/789"; - process.env.GH_AW_CREATED_DISCUSSION_NUMBER = "789"; - process.env.GH_AW_CREATED_PULL_REQUEST_URL = "https://github.com/testowner/testrepo/pull/101"; - process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER = "101"; - - const mockComment = { - id: 890, - html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-890", - }; - - mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }); - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0]; - - // Check that the related items section uses header level 4 (####) - expect(callArgs.body).toContain("#### Related Items"); - // Check that it uses exactly 4 hashes, not 2 - expect(callArgs.body).toMatch(/####\s+Related Items/); - expect(callArgs.body).not.toMatch(/^##\s+Related Items/m); - expect(callArgs.body).not.toMatch(/\*\*Related Items:\*\*/); - - // Check that the references are included - expect(callArgs.body).toContain("- Issue: [#456](https://github.com/testowner/testrepo/issues/456)"); - expect(callArgs.body).toContain("- Discussion: [#789](https://github.com/testowner/testrepo/discussions/789)"); - expect(callArgs.body).toContain("- Pull Request: [#101](https://github.com/testowner/testrepo/pull/101)"); - - // Clean up - delete process.env.GH_AW_CREATED_ISSUE_URL; - delete process.env.GH_AW_CREATED_ISSUE_NUMBER; - delete process.env.GH_AW_CREATED_DISCUSSION_URL; - delete process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - delete process.env.GH_AW_CREATED_PULL_REQUEST_URL; - delete process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - }); - - it("should use header level 4 for related items in staged mode preview", async () => { - setAgentOutput({ - items: [ - { - type: "add_comment", - body: "Test comment in staged mode", - }, - ], - }); - global.context.eventName = "issues"; - global.context.payload.issue = { number: 123 }; - - // Enable staged mode - process.env.GH_AW_SAFE_OUTPUTS_STAGED = "true"; - - // Set environment variables for created items - process.env.GH_AW_CREATED_ISSUE_URL = "https://github.com/testowner/testrepo/issues/456"; - process.env.GH_AW_CREATED_ISSUE_NUMBER = "456"; - process.env.GH_AW_CREATED_DISCUSSION_URL = "https://github.com/testowner/testrepo/discussions/789"; - process.env.GH_AW_CREATED_DISCUSSION_NUMBER = "789"; - process.env.GH_AW_CREATED_PULL_REQUEST_URL = "https://github.com/testowner/testrepo/pull/101"; - process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER = "101"; - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - // Check that summary was written with correct header level 4 - expect(mockCore.summary.addRaw).toHaveBeenCalled(); - const summaryContent = mockCore.summary.addRaw.mock.calls[0][0]; - - // Check that the related items section uses header level 4 (####) - expect(summaryContent).toContain("#### Related Items"); - // Check that it uses exactly 4 hashes, not 2 - expect(summaryContent).toMatch(/####\s+Related Items/); - expect(summaryContent).not.toMatch(/^##\s+Related Items/m); - expect(summaryContent).not.toMatch(/\*\*Related Items:\*\*/); - - // Check that the references are included - expect(summaryContent).toContain("- Issue: [#456](https://github.com/testowner/testrepo/issues/456)"); - expect(summaryContent).toContain("- Discussion: [#789](https://github.com/testowner/testrepo/discussions/789)"); - expect(summaryContent).toContain("- Pull Request: [#101](https://github.com/testowner/testrepo/pull/101)"); - - // Clean up - delete process.env.GH_AW_SAFE_OUTPUTS_STAGED; - delete process.env.GH_AW_CREATED_ISSUE_URL; - delete process.env.GH_AW_CREATED_ISSUE_NUMBER; - delete process.env.GH_AW_CREATED_DISCUSSION_URL; - delete process.env.GH_AW_CREATED_DISCUSSION_NUMBER; - delete process.env.GH_AW_CREATED_PULL_REQUEST_URL; - delete process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER; - }); - - it("should create comment on discussion using GraphQL when in discussion_comment context", async () => { - setAgentOutput({ - items: [ - { - type: "add_comment", - body: "Test discussion comment", - }, - ], - }); - - // Simulate discussion_comment context - global.context.eventName = "discussion_comment"; - global.context.payload.discussion = { number: 1993 }; - global.context.payload.comment = { - id: 12345, - node_id: "DC_kwDOABcD1M4AaBbC", // Node ID of the comment to reply to - }; - delete global.context.payload.issue; - delete global.context.payload.pull_request; - - // Mock GraphQL responses for discussion - const mockGraphqlResponse = vi.fn(); - mockGraphqlResponse - .mockResolvedValueOnce({ - // First call: get discussion ID - repository: { - discussion: { - id: "D_kwDOPc1QR84BpqRs", - url: "https://github.com/testowner/testrepo/discussions/1993", - }, - }, - }) - .mockResolvedValueOnce({ - // Second call: create comment with replyToId - addDiscussionComment: { - comment: { - id: "DC_kwDOPc1QR84BpqRt", - body: "Test discussion comment", - createdAt: "2025-10-19T22:00:00Z", - url: "https://github.com/testowner/testrepo/discussions/1993#discussioncomment-123", - }, - }, - }); - - global.github.graphql = mockGraphqlResponse; - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - // Verify GraphQL was called with correct queries - expect(mockGraphqlResponse).toHaveBeenCalledTimes(2); - - // First call should fetch discussion ID - expect(mockGraphqlResponse.mock.calls[0][0]).toContain("query"); - expect(mockGraphqlResponse.mock.calls[0][0]).toContain("discussion(number: $num)"); - expect(mockGraphqlResponse.mock.calls[0][1]).toEqual({ - owner: "testowner", - repo: "testrepo", - num: 1993, - }); - - // Second call should create the comment with replyToId - expect(mockGraphqlResponse.mock.calls[1][0]).toContain("mutation"); - expect(mockGraphqlResponse.mock.calls[1][0]).toContain("addDiscussionComment"); - expect(mockGraphqlResponse.mock.calls[1][0]).toContain("replyToId"); - expect(mockGraphqlResponse.mock.calls[1][1].body).toContain("Test discussion comment"); - expect(mockGraphqlResponse.mock.calls[1][1].replyToId).toBe("DC_kwDOABcD1M4AaBbC"); - - // Verify REST API was NOT called - expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(); - - // Verify outputs were set - expect(mockCore.setOutput).toHaveBeenCalledWith("comment_id", "DC_kwDOPc1QR84BpqRt"); - expect(mockCore.setOutput).toHaveBeenCalledWith("comment_url", "https://github.com/testowner/testrepo/discussions/1993#discussioncomment-123"); - - // Clean up - delete global.github.graphql; - delete global.context.payload.discussion; - delete global.context.payload.comment; - }); - - it("should create comment on discussion using GraphQL when GITHUB_AW_COMMENT_DISCUSSION is true (explicit discussion mode)", async () => { - setAgentOutput({ - items: [ - { - type: "add_comment", - body: "Test explicit discussion comment", - item_number: 2001, - }, - ], - }); - - // Set target configuration to use explicit number - process.env.GH_AW_COMMENT_TARGET = "*"; - // Force discussion mode via environment variable - process.env.GITHUB_AW_COMMENT_DISCUSSION = "true"; - - // Use a non-discussion context (e.g., issues) to test explicit override - global.context.eventName = "issues"; - global.context.payload.issue = { number: 123 }; - delete global.context.payload.discussion; - delete global.context.payload.pull_request; - - // Mock GraphQL responses for discussion - const mockGraphqlResponse = vi.fn(); - mockGraphqlResponse - .mockResolvedValueOnce({ - // First call: get discussion ID - repository: { - discussion: { - id: "D_kwDOPc1QR84BpqRu", - url: "https://github.com/testowner/testrepo/discussions/2001", - }, - }, - }) - .mockResolvedValueOnce({ - // Second call: create comment (no replyToId for non-comment context) - addDiscussionComment: { - comment: { - id: "DC_kwDOPc1QR84BpqRv", - body: "Test explicit discussion comment", - createdAt: "2025-10-22T12:00:00Z", - url: "https://github.com/testowner/testrepo/discussions/2001#discussioncomment-456", - }, - }, - }); - - global.github.graphql = mockGraphqlResponse; - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - // Verify GraphQL was called with correct queries - expect(mockGraphqlResponse).toHaveBeenCalledTimes(2); - - // First call should fetch discussion ID for the explicit number - expect(mockGraphqlResponse.mock.calls[0][0]).toContain("query"); - expect(mockGraphqlResponse.mock.calls[0][0]).toContain("discussion(number: $num)"); - expect(mockGraphqlResponse.mock.calls[0][1]).toEqual({ - owner: "testowner", - repo: "testrepo", - num: 2001, // Should use the item_number from the comment item - }); - - // Second call should create the comment (without replyToId since this is not discussion_comment context) - expect(mockGraphqlResponse.mock.calls[1][0]).toContain("mutation"); - expect(mockGraphqlResponse.mock.calls[1][0]).toContain("addDiscussionComment"); - expect(mockGraphqlResponse.mock.calls[1][1].body).toContain("Test explicit discussion comment"); - // Should NOT have replyToId since we're not in discussion_comment context - expect(mockGraphqlResponse.mock.calls[1][1].replyToId).toBeUndefined(); - - // Verify REST API was NOT called (should use GraphQL for discussions) - expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(); - - // Verify outputs were set - expect(mockCore.setOutput).toHaveBeenCalledWith("comment_id", "DC_kwDOPc1QR84BpqRv"); - expect(mockCore.setOutput).toHaveBeenCalledWith("comment_url", "https://github.com/testowner/testrepo/discussions/2001#discussioncomment-456"); - - // Verify info logging shows it's targeting a discussion - expect(mockCore.info).toHaveBeenCalledWith("Creating comment on discussion #2001"); - - // Clean up - delete process.env.GH_AW_COMMENT_TARGET; - delete process.env.GITHUB_AW_COMMENT_DISCUSSION; - delete global.github.graphql; - }); - - it("should replace temporary ID references in comment body using the temporary ID map", async () => { - setAgentOutput({ - items: [ - { - type: "add_comment", - body: "This comment references issue #aw_aabbccdd1122 which was created earlier.", - }, - ], - }); - - // Set up the temporary ID map from the create_issue job - process.env.GH_AW_TEMPORARY_ID_MAP = JSON.stringify({ aw_aabbccdd1122: 456 }); - - mockGithub.rest.issues.createComment.mockResolvedValue({ - data: { - id: 99999, - html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-99999", - }, - }); - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - // The comment body should have the temporary ID replaced - expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.stringContaining("#456"), - }) - ); - expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.not.stringContaining("#aw_aabbccdd1122"), - }) - ); - - // Clean up - delete process.env.GH_AW_TEMPORARY_ID_MAP; - }); - - it("should load temporary ID map and log the count", async () => { - setAgentOutput({ - items: [ - { - type: "add_comment", - body: "Test comment", - }, - ], - }); - - // Set up the temporary ID map - process.env.GH_AW_TEMPORARY_ID_MAP = JSON.stringify({ aw_abc123: 100, aw_def456: 200 }); - - mockGithub.rest.issues.createComment.mockResolvedValue({ - data: { - id: 99999, - html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-99999", - }, - }); - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - // Should log that the map was loaded with 2 entries - expect(mockCore.info).toHaveBeenCalledWith("Loaded temporary ID map with 2 entries"); - - // Clean up - delete process.env.GH_AW_TEMPORARY_ID_MAP; - }); - - it("should handle empty temporary ID map gracefully", async () => { - setAgentOutput({ - items: [ - { - type: "add_comment", - body: "Comment with #aw_000000000000 that won't be resolved", - }, - ], - }); - - // Empty or missing temporary ID map - process.env.GH_AW_TEMPORARY_ID_MAP = "{}"; - - mockGithub.rest.issues.createComment.mockResolvedValue({ - data: { - id: 99999, - html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-99999", - }, - }); - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - // The unresolved reference should remain in the body - expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.stringContaining("#aw_000000000000"), - }) - ); - - // Clean up - delete process.env.GH_AW_TEMPORARY_ID_MAP; - }); - - it("should use custom footer message when GH_AW_SAFE_OUTPUT_MESSAGES is configured", async () => { - setAgentOutput({ - items: [ - { - type: "add_comment", - body: "Test comment with custom footer", - }, - ], - }); - process.env.GH_AW_WORKFLOW_NAME = "Custom Workflow"; - process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ - footer: "> Custom AI footer by [{workflow_name}]({run_url})", - footerInstall: "> Custom install: `gh aw add {workflow_source}`", - }); - global.context.eventName = "issues"; - global.context.payload.issue = { number: 456 }; - - const mockComment = { - id: 999, - html_url: "https://github.com/testowner/testrepo/issues/456#issuecomment-999", - }; - - mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }); - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0]; - - // Check that the custom footer is used - expect(callArgs.body).toContain("Test comment with custom footer"); - expect(callArgs.body).toContain("Custom AI footer by [Custom Workflow]"); - expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345"); - // Should NOT contain the pirate-themed default footer - expect(callArgs.body).not.toContain("Ahoy!"); - expect(callArgs.body).not.toContain("treasure was crafted"); - - // Clean up - delete process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - }); - - it("should use custom footer with install instructions when workflow source is provided", async () => { - setAgentOutput({ - items: [ - { - type: "add_comment", - body: "Test comment with custom footer and install", - }, - ], - }); - process.env.GH_AW_WORKFLOW_NAME = "Custom Workflow"; - process.env.GH_AW_WORKFLOW_SOURCE = "owner/repo/workflow.md@main"; - process.env.GH_AW_WORKFLOW_SOURCE_URL = "https://github.com/owner/repo"; - process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ - footer: "> Generated by [{workflow_name}]({run_url})", - footerInstall: "> Install: `gh aw add {workflow_source}`", - }); - global.context.eventName = "issues"; - global.context.payload.issue = { number: 789 }; - - const mockComment = { - id: 1001, - html_url: "https://github.com/testowner/testrepo/issues/789#issuecomment-1001", - }; - - mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }); - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0]; - - // Check that the custom footer and install instructions are used - expect(callArgs.body).toContain("Test comment with custom footer and install"); - expect(callArgs.body).toContain("Generated by [Custom Workflow]"); - expect(callArgs.body).toContain("Install: `gh aw add owner/repo/workflow.md@main`"); - // Should NOT contain the pirate-themed default messages - expect(callArgs.body).not.toContain("Ahoy!"); - expect(callArgs.body).not.toContain("plunder this workflow"); - - // Clean up - delete process.env.GH_AW_SAFE_OUTPUT_MESSAGES; - delete process.env.GH_AW_WORKFLOW_SOURCE; - delete process.env.GH_AW_WORKFLOW_SOURCE_URL; - }); - - it("should hide older comments when hide-older-comments is enabled", async () => { - setAgentOutput({ - items: [ - { - type: "add_comment", - body: "New comment from workflow", - }, - ], - }); - process.env.GITHUB_WORKFLOW = "test-workflow-123"; - process.env.GH_AW_HIDE_OLDER_COMMENTS = "true"; - global.context.eventName = "issues"; - global.context.payload.issue = { number: 100 }; - - // Mock existing comments with the same workflow-id - mockGithub.rest.issues.listComments = vi.fn().mockResolvedValue({ - data: [ - { - id: 1, - node_id: "IC_oldcomment1", - body: "Old comment 1\n\n", - }, - { - id: 2, - node_id: "IC_oldcomment2", - body: "Old comment 2\n\n", - }, - { - id: 3, - node_id: "IC_othercomment", - body: "Comment from different workflow", - }, - ], - }); - - // Mock the minimizeComment GraphQL mutation - mockGithub.graphql = vi.fn().mockResolvedValue({ - minimizeComment: { - minimizedComment: { - isMinimized: true, - }, - }, - }); - - const mockNewComment = { - id: 4, - html_url: "https://github.com/testowner/testrepo/issues/100#issuecomment-4", - }; - mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }); - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - // Verify that listComments was called - expect(mockGithub.rest.issues.listComments).toHaveBeenCalledWith({ - owner: "testowner", - repo: "testrepo", - issue_number: 100, - per_page: 100, - page: 1, - }); - - // Verify that minimizeComment was called twice (for the two matching comments) - expect(mockGithub.graphql).toHaveBeenCalledTimes(2); - expect(mockGithub.graphql).toHaveBeenCalledWith( - expect.stringContaining("minimizeComment"), - expect.objectContaining({ - nodeId: "IC_oldcomment1", - classifier: "OUTDATED", - }) - ); - expect(mockGithub.graphql).toHaveBeenCalledWith( - expect.stringContaining("minimizeComment"), - expect.objectContaining({ - nodeId: "IC_oldcomment2", - classifier: "OUTDATED", - }) - ); - - // Verify that the new comment was created - expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith( - expect.objectContaining({ - owner: "testowner", - repo: "testrepo", - issue_number: 100, - body: expect.stringContaining("New comment from workflow"), - }) - ); - - // Clean up - delete process.env.GITHUB_WORKFLOW; - delete process.env.GH_AW_HIDE_OLDER_COMMENTS; - }); - - it("should not hide comments when hide-older-comments is not enabled", async () => { - setAgentOutput({ - items: [ - { - type: "add_comment", - body: "New comment without hiding", - }, - ], - }); - process.env.GITHUB_WORKFLOW = "test-workflow-456"; - // Note: GH_AW_HIDE_OLDER_COMMENTS is not set - global.context.eventName = "issues"; - global.context.payload.issue = { number: 200 }; - - mockGithub.rest.issues.listComments = vi.fn(); - mockGithub.graphql = vi.fn(); - - const mockNewComment = { - id: 5, - html_url: "https://github.com/testowner/testrepo/issues/200#issuecomment-5", - }; - mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }); - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - // Verify that listComments was NOT called (hide feature not enabled) - expect(mockGithub.rest.issues.listComments).not.toHaveBeenCalled(); - expect(mockGithub.graphql).not.toHaveBeenCalled(); - - // Verify that the new comment was created - expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(); - - // Clean up - delete process.env.GITHUB_WORKFLOW; - }); - - it("should skip hiding when workflow-id is not available", async () => { - setAgentOutput({ - items: [ - { - type: "add_comment", - body: "Comment without workflow-id", - }, - ], - }); - // Note: GITHUB_WORKFLOW is not set - process.env.GH_AW_HIDE_OLDER_COMMENTS = "true"; - global.context.eventName = "issues"; - global.context.payload.issue = { number: 300 }; - - mockGithub.rest.issues.listComments = vi.fn(); - mockGithub.graphql = vi.fn(); - - const mockNewComment = { - id: 6, - html_url: "https://github.com/testowner/testrepo/issues/300#issuecomment-6", - }; - mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }); - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - // Verify that hiding was skipped (no workflow-id available) - expect(mockGithub.rest.issues.listComments).not.toHaveBeenCalled(); - expect(mockGithub.graphql).not.toHaveBeenCalled(); - - // Verify that the new comment was created - expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(); - - // Clean up - delete process.env.GH_AW_HIDE_OLDER_COMMENTS; - }); - - it("should respect allowed-reasons when hiding comments", async () => { - setAgentOutput({ - items: [ - { - type: "add_comment", - body: "New comment with allowed reasons", - }, - ], - }); - process.env.GITHUB_WORKFLOW = "test-workflow-789"; - process.env.GH_AW_HIDE_OLDER_COMMENTS = "true"; - process.env.GH_AW_ALLOWED_REASONS = JSON.stringify(["OUTDATED", "RESOLVED"]); - global.context.eventName = "issues"; - global.context.payload.issue = { number: 400 }; - - // Mock existing comments - mockGithub.rest.issues.listComments = vi.fn().mockResolvedValue({ - data: [ - { - id: 1, - node_id: "IC_oldcomment1", - body: "Old comment\n\n", - }, - ], - }); - - // Mock the minimizeComment GraphQL mutation - mockGithub.graphql = vi.fn().mockResolvedValue({ - minimizeComment: { - minimizedComment: { - isMinimized: true, - }, - }, - }); - - const mockNewComment = { - id: 2, - html_url: "https://github.com/testowner/testrepo/issues/400#issuecomment-2", - }; - mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }); - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - // Verify that minimizeComment was called with OUTDATED (the default) - expect(mockGithub.graphql).toHaveBeenCalledWith( - expect.stringContaining("minimizeComment"), - expect.objectContaining({ - nodeId: "IC_oldcomment1", - classifier: "OUTDATED", - }) - ); - - // Clean up - delete process.env.GITHUB_WORKFLOW; - delete process.env.GH_AW_HIDE_OLDER_COMMENTS; - delete process.env.GH_AW_ALLOWED_REASONS; - }); - - it("should skip hiding when reason is not in allowed-reasons", async () => { - setAgentOutput({ - items: [ - { - type: "add_comment", - body: "New comment with restricted reasons", - }, - ], - }); - process.env.GITHUB_WORKFLOW = "test-workflow-999"; - process.env.GH_AW_HIDE_OLDER_COMMENTS = "true"; - // Only allow SPAM, but default reason is OUTDATED - process.env.GH_AW_ALLOWED_REASONS = JSON.stringify(["SPAM"]); - global.context.eventName = "issues"; - global.context.payload.issue = { number: 500 }; - - mockGithub.rest.issues.listComments = vi.fn(); - mockGithub.graphql = vi.fn(); - - const mockNewComment = { - id: 3, - html_url: "https://github.com/testowner/testrepo/issues/500#issuecomment-3", - }; - mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }); - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - // Verify that hiding was skipped (OUTDATED not in allowed reasons) - expect(mockGithub.rest.issues.listComments).not.toHaveBeenCalled(); - expect(mockGithub.graphql).not.toHaveBeenCalled(); - - // Verify that the new comment was still created - expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(); - - // Clean up - delete process.env.GITHUB_WORKFLOW; - delete process.env.GH_AW_HIDE_OLDER_COMMENTS; - delete process.env.GH_AW_ALLOWED_REASONS; - }); - - it("should support lowercase allowed-reasons", async () => { - setAgentOutput({ - items: [ - { - type: "add_comment", - body: "New comment with lowercase reasons", - }, - ], - }); - process.env.GITHUB_WORKFLOW = "test-workflow-lowercase"; - process.env.GH_AW_HIDE_OLDER_COMMENTS = "true"; - // Use lowercase reasons - process.env.GH_AW_ALLOWED_REASONS = JSON.stringify(["outdated", "resolved"]); - global.context.eventName = "issues"; - global.context.payload.issue = { number: 600 }; - - // Mock existing comments - mockGithub.rest.issues.listComments = vi.fn().mockResolvedValue({ - data: [ - { - id: 1, - node_id: "IC_oldcomment1", - body: "Old comment\n\n", - }, - ], - }); - - // Mock the minimizeComment GraphQL mutation - mockGithub.graphql = vi.fn().mockResolvedValue({ - minimizeComment: { - minimizedComment: { - isMinimized: true, - }, - }, - }); - - const mockNewComment = { - id: 4, - html_url: "https://github.com/testowner/testrepo/issues/600#issuecomment-4", + mockGithub = { rest: { issues: { createComment: vi.fn() } } }, + mockContext = { eventName: "issues", runId: 12345, repo: { owner: "testowner", repo: "testrepo" }, payload: { issue: { number: 123 }, repository: { html_url: "https://github.com/testowner/testrepo" } } }; +((global.core = mockCore), + (global.github = mockGithub), + (global.context = mockContext), + describe("add_comment.cjs", () => { + let createCommentScript, tempFilePath; + const setAgentOutput = data => { + tempFilePath = path.join("/tmp", `test_agent_output_${Date.now()}_${Math.random().toString(36).slice(2)}.json`); + const content = "string" == typeof data ? data : JSON.stringify(data); + (fs.writeFileSync(tempFilePath, content), (process.env.GH_AW_AGENT_OUTPUT = tempFilePath)); }; - mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }); - - // Execute the script - await eval(`(async () => { ${createCommentScript} })()`); - - // Verify that minimizeComment was called with OUTDATED (uppercase, normalized) - expect(mockGithub.graphql).toHaveBeenCalledWith( - expect.stringContaining("minimizeComment"), - expect.objectContaining({ - nodeId: "IC_oldcomment1", - classifier: "OUTDATED", - }) - ); - - // Clean up - delete process.env.GITHUB_WORKFLOW; - delete process.env.GH_AW_HIDE_OLDER_COMMENTS; - delete process.env.GH_AW_ALLOWED_REASONS; - }); -}); + (beforeEach(() => { + (vi.clearAllMocks(), delete process.env.GH_AW_AGENT_OUTPUT, delete process.env.GITHUB_WORKFLOW, (global.context.eventName = "issues"), (global.context.payload.issue = { number: 123 })); + const scriptPath = path.join(process.cwd(), "add_comment.cjs"); + createCommentScript = fs.readFileSync(scriptPath, "utf8"); + }), + afterEach(() => { + tempFilePath && require("fs").existsSync(tempFilePath) && (require("fs").unlinkSync(tempFilePath), (tempFilePath = void 0)); + }), + it("should skip when no agent output is provided", async () => { + (delete process.env.GH_AW_AGENT_OUTPUT, + await eval(`(async () => { ${createCommentScript} })()`), + expect(mockCore.info).toHaveBeenCalledWith("No GH_AW_AGENT_OUTPUT environment variable found"), + expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled()); + }), + it("should skip when agent output is empty", async () => { + (setAgentOutput(""), await eval(`(async () => { ${createCommentScript} })()`), expect(mockCore.info).toHaveBeenCalledWith("Agent output content is empty"), expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled()); + }), + it("should skip when not in issue or PR context", async () => { + (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment content" }] }), + (global.context.eventName = "push"), + await eval(`(async () => { ${createCommentScript} })()`), + expect(mockCore.info).toHaveBeenCalledWith('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'), + expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled()); + }), + it("should create comment on issue successfully", async () => { + (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment content" }] }), (global.context.eventName = "issues")); + const mockComment = { id: 456, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456" }; + (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), + await eval(`(async () => { ${createCommentScript} })()`), + expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({ owner: "testowner", repo: "testrepo", issue_number: 123, body: expect.stringContaining("Test comment content") }), + expect(mockCore.setOutput).toHaveBeenCalledWith("comment_id", 456), + expect(mockCore.setOutput).toHaveBeenCalledWith("comment_url", mockComment.html_url), + expect(mockCore.summary.addRaw).toHaveBeenCalled(), + expect(mockCore.summary.write).toHaveBeenCalled()); + }), + it("should create comment on pull request successfully", async () => { + (setAgentOutput({ items: [{ type: "add_comment", body: "Test PR comment content" }] }), (global.context.eventName = "pull_request"), (global.context.payload.pull_request = { number: 789 }), delete global.context.payload.issue); + const mockComment = { id: 789, html_url: "https://github.com/testowner/testrepo/issues/789#issuecomment-789" }; + (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), + await eval(`(async () => { ${createCommentScript} })()`), + expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({ owner: "testowner", repo: "testrepo", issue_number: 789, body: expect.stringContaining("Test PR comment content") })); + }), + it("should include run information in comment body", async () => { + (setAgentOutput({ items: [{ type: "add_comment", body: "Test content" }] }), (global.context.eventName = "issues"), (global.context.payload.issue = { number: 123 })); + const mockComment = { id: 456, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456" }; + (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), + await eval(`(async () => { ${createCommentScript} })()`), + expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(), + expect(mockGithub.rest.issues.createComment.mock.calls).toHaveLength(1)); + const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0]; + (expect(callArgs.body).toContain("Test content"), expect(callArgs.body).toContain("This treasure was crafted by"), expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345")); + }), + it("should include workflow source in footer when GH_AW_WORKFLOW_SOURCE is provided", async () => { + (setAgentOutput({ items: [{ type: "add_comment", body: "Test content with source" }] }), + (process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"), + (process.env.GH_AW_WORKFLOW_SOURCE = "githubnext/agentics/workflows/ci-doctor.md@v1.0.0"), + (process.env.GH_AW_WORKFLOW_SOURCE_URL = "https://github.com/githubnext/agentics/tree/v1.0.0/workflows/ci-doctor.md"), + (global.context.eventName = "issues"), + (global.context.payload.issue = { number: 123 })); + const mockComment = { id: 456, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456" }; + (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript} })()`), expect(mockGithub.rest.issues.createComment).toHaveBeenCalled()); + const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0]; + (expect(callArgs.body).toContain("Test content with source"), + expect(callArgs.body).toContain("[🏴‍☠️ Test Workflow]"), + expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345"), + expect(callArgs.body).toContain("gh aw add githubnext/agentics/workflows/ci-doctor.md@v1.0.0")); + }), + it("should not include workflow source footer when GH_AW_WORKFLOW_SOURCE is not provided", async () => { + (setAgentOutput({ items: [{ type: "add_comment", body: "Test content without source" }] }), + (process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"), + delete process.env.GH_AW_WORKFLOW_SOURCE, + (global.context.eventName = "issues"), + (global.context.payload.issue = { number: 123 })); + const mockComment = { id: 456, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456" }; + (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript} })()`), expect(mockGithub.rest.issues.createComment).toHaveBeenCalled()); + const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0]; + (expect(callArgs.body).toContain("Test content without source"), expect(callArgs.body).toContain("[🏴‍☠️ Test Workflow]"), expect(callArgs.body).not.toContain("gh aw add")); + }), + it("should use GITHUB_SERVER_URL when repository context is not available", async () => { + (setAgentOutput({ items: [{ type: "add_comment", body: "Test content with custom server" }] }), + (process.env.GITHUB_SERVER_URL = "https://github.enterprise.com"), + (global.context.eventName = "issues"), + (global.context.payload.issue = { number: 123 }), + delete global.context.payload.repository); + const mockComment = { id: 456, html_url: "https://github.enterprise.com/testowner/testrepo/issues/123#issuecomment-456" }; + (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript} })()`), expect(mockGithub.rest.issues.createComment).toHaveBeenCalled()); + const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0]; + (expect(callArgs.body).toContain("Test content with custom server"), + expect(callArgs.body).toContain("https://github.enterprise.com/testowner/testrepo/actions/runs/12345"), + expect(callArgs.body).not.toContain("https://github.com/testowner/testrepo/actions/runs/12345"), + delete process.env.GITHUB_SERVER_URL); + }), + it("should fallback to https://github.com when GITHUB_SERVER_URL is not set and repository context is missing", async () => { + (setAgentOutput({ items: [{ type: "add_comment", body: "Test content with fallback" }] }), + delete process.env.GITHUB_SERVER_URL, + (global.context.eventName = "issues"), + (global.context.payload.issue = { number: 123 }), + delete global.context.payload.repository); + const mockComment = { id: 456, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456" }; + (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript} })()`), expect(mockGithub.rest.issues.createComment).toHaveBeenCalled()); + const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0]; + (expect(callArgs.body).toContain("Test content with fallback"), expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345")); + }), + it("should include triggering issue number in footer when in issue context", async () => { + (setAgentOutput({ items: [{ type: "add_comment", body: "Comment from issue context" }] }), (process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"), (global.context.eventName = "issues"), (global.context.payload.issue = { number: 42 })); + const mockComment = { id: 789, html_url: "https://github.com/testowner/testrepo/issues/42#issuecomment-789" }; + (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript} })()`)); + const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0]; + (expect(callArgs.body).toContain("Comment from issue context"), expect(callArgs.body).toContain("[🏴‍☠️ Test Workflow]"), expect(callArgs.body).toContain("#42")); + }), + it("should include triggering PR number in footer when in PR context", async () => { + (setAgentOutput({ items: [{ type: "add_comment", body: "Comment from PR context" }] }), + (process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"), + (global.context.eventName = "pull_request"), + delete global.context.payload.issue, + (global.context.payload.pull_request = { number: 123 })); + const mockComment = { id: 890, html_url: "https://github.com/testowner/testrepo/pull/123#issuecomment-890" }; + (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript} })()`)); + const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0]; + (expect(callArgs.body).toContain("Comment from PR context"), expect(callArgs.body).toContain("[🏴‍☠️ Test Workflow]"), expect(callArgs.body).toContain("#123"), delete global.context.payload.pull_request); + }), + it("should use header level 4 for related items in comments", async () => { + (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment with related items" }] }), + (global.context.eventName = "issues"), + (global.context.payload.issue = { number: 123 }), + (process.env.GH_AW_CREATED_ISSUE_URL = "https://github.com/testowner/testrepo/issues/456"), + (process.env.GH_AW_CREATED_ISSUE_NUMBER = "456"), + (process.env.GH_AW_CREATED_DISCUSSION_URL = "https://github.com/testowner/testrepo/discussions/789"), + (process.env.GH_AW_CREATED_DISCUSSION_NUMBER = "789"), + (process.env.GH_AW_CREATED_PULL_REQUEST_URL = "https://github.com/testowner/testrepo/pull/101"), + (process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER = "101")); + const mockComment = { id: 890, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-890" }; + (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript} })()`)); + const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0]; + (expect(callArgs.body).toContain("#### Related Items"), + expect(callArgs.body).toMatch(/####\s+Related Items/), + expect(callArgs.body).not.toMatch(/^##\s+Related Items/m), + expect(callArgs.body).not.toMatch(/\*\*Related Items:\*\*/), + expect(callArgs.body).toContain("- Issue: [#456](https://github.com/testowner/testrepo/issues/456)"), + expect(callArgs.body).toContain("- Discussion: [#789](https://github.com/testowner/testrepo/discussions/789)"), + expect(callArgs.body).toContain("- Pull Request: [#101](https://github.com/testowner/testrepo/pull/101)"), + delete process.env.GH_AW_CREATED_ISSUE_URL, + delete process.env.GH_AW_CREATED_ISSUE_NUMBER, + delete process.env.GH_AW_CREATED_DISCUSSION_URL, + delete process.env.GH_AW_CREATED_DISCUSSION_NUMBER, + delete process.env.GH_AW_CREATED_PULL_REQUEST_URL, + delete process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER); + }), + it("should use header level 4 for related items in staged mode preview", async () => { + (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment in staged mode" }] }), + (global.context.eventName = "issues"), + (global.context.payload.issue = { number: 123 }), + (process.env.GH_AW_SAFE_OUTPUTS_STAGED = "true"), + (process.env.GH_AW_CREATED_ISSUE_URL = "https://github.com/testowner/testrepo/issues/456"), + (process.env.GH_AW_CREATED_ISSUE_NUMBER = "456"), + (process.env.GH_AW_CREATED_DISCUSSION_URL = "https://github.com/testowner/testrepo/discussions/789"), + (process.env.GH_AW_CREATED_DISCUSSION_NUMBER = "789"), + (process.env.GH_AW_CREATED_PULL_REQUEST_URL = "https://github.com/testowner/testrepo/pull/101"), + (process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER = "101"), + await eval(`(async () => { ${createCommentScript} })()`), + expect(mockCore.summary.addRaw).toHaveBeenCalled()); + const summaryContent = mockCore.summary.addRaw.mock.calls[0][0]; + (expect(summaryContent).toContain("#### Related Items"), + expect(summaryContent).toMatch(/####\s+Related Items/), + expect(summaryContent).not.toMatch(/^##\s+Related Items/m), + expect(summaryContent).not.toMatch(/\*\*Related Items:\*\*/), + expect(summaryContent).toContain("- Issue: [#456](https://github.com/testowner/testrepo/issues/456)"), + expect(summaryContent).toContain("- Discussion: [#789](https://github.com/testowner/testrepo/discussions/789)"), + expect(summaryContent).toContain("- Pull Request: [#101](https://github.com/testowner/testrepo/pull/101)"), + delete process.env.GH_AW_SAFE_OUTPUTS_STAGED, + delete process.env.GH_AW_CREATED_ISSUE_URL, + delete process.env.GH_AW_CREATED_ISSUE_NUMBER, + delete process.env.GH_AW_CREATED_DISCUSSION_URL, + delete process.env.GH_AW_CREATED_DISCUSSION_NUMBER, + delete process.env.GH_AW_CREATED_PULL_REQUEST_URL, + delete process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER); + }), + it("should create comment on discussion using GraphQL when in discussion_comment context", async () => { + (setAgentOutput({ items: [{ type: "add_comment", body: "Test discussion comment" }] }), + (global.context.eventName = "discussion_comment"), + (global.context.payload.discussion = { number: 1993 }), + (global.context.payload.comment = { id: 12345, node_id: "DC_kwDOABcD1M4AaBbC" }), + delete global.context.payload.issue, + delete global.context.payload.pull_request); + const mockGraphqlResponse = vi.fn(); + (mockGraphqlResponse + .mockResolvedValueOnce({ repository: { discussion: { id: "D_kwDOPc1QR84BpqRs", url: "https://github.com/testowner/testrepo/discussions/1993" } } }) + .mockResolvedValueOnce({ + addDiscussionComment: { comment: { id: "DC_kwDOPc1QR84BpqRt", body: "Test discussion comment", createdAt: "2025-10-19T22:00:00Z", url: "https://github.com/testowner/testrepo/discussions/1993#discussioncomment-123" } }, + }), + (global.github.graphql = mockGraphqlResponse), + await eval(`(async () => { ${createCommentScript} })()`), + expect(mockGraphqlResponse).toHaveBeenCalledTimes(2), + expect(mockGraphqlResponse.mock.calls[0][0]).toContain("query"), + expect(mockGraphqlResponse.mock.calls[0][0]).toContain("discussion(number: $num)"), + expect(mockGraphqlResponse.mock.calls[0][1]).toEqual({ owner: "testowner", repo: "testrepo", num: 1993 }), + expect(mockGraphqlResponse.mock.calls[1][0]).toContain("mutation"), + expect(mockGraphqlResponse.mock.calls[1][0]).toContain("addDiscussionComment"), + expect(mockGraphqlResponse.mock.calls[1][0]).toContain("replyToId"), + expect(mockGraphqlResponse.mock.calls[1][1].body).toContain("Test discussion comment"), + expect(mockGraphqlResponse.mock.calls[1][1].replyToId).toBe("DC_kwDOABcD1M4AaBbC"), + expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(), + expect(mockCore.setOutput).toHaveBeenCalledWith("comment_id", "DC_kwDOPc1QR84BpqRt"), + expect(mockCore.setOutput).toHaveBeenCalledWith("comment_url", "https://github.com/testowner/testrepo/discussions/1993#discussioncomment-123"), + delete global.github.graphql, + delete global.context.payload.discussion, + delete global.context.payload.comment); + }), + it("should create comment on discussion using GraphQL when GITHUB_AW_COMMENT_DISCUSSION is true (explicit discussion mode)", async () => { + (setAgentOutput({ items: [{ type: "add_comment", body: "Test explicit discussion comment", item_number: 2001 }] }), + (process.env.GH_AW_COMMENT_TARGET = "*"), + (process.env.GITHUB_AW_COMMENT_DISCUSSION = "true"), + (global.context.eventName = "issues"), + (global.context.payload.issue = { number: 123 }), + delete global.context.payload.discussion, + delete global.context.payload.pull_request); + const mockGraphqlResponse = vi.fn(); + (mockGraphqlResponse + .mockResolvedValueOnce({ repository: { discussion: { id: "D_kwDOPc1QR84BpqRu", url: "https://github.com/testowner/testrepo/discussions/2001" } } }) + .mockResolvedValueOnce({ + addDiscussionComment: { comment: { id: "DC_kwDOPc1QR84BpqRv", body: "Test explicit discussion comment", createdAt: "2025-10-22T12:00:00Z", url: "https://github.com/testowner/testrepo/discussions/2001#discussioncomment-456" } }, + }), + (global.github.graphql = mockGraphqlResponse), + await eval(`(async () => { ${createCommentScript} })()`), + expect(mockGraphqlResponse).toHaveBeenCalledTimes(2), + expect(mockGraphqlResponse.mock.calls[0][0]).toContain("query"), + expect(mockGraphqlResponse.mock.calls[0][0]).toContain("discussion(number: $num)"), + expect(mockGraphqlResponse.mock.calls[0][1]).toEqual({ owner: "testowner", repo: "testrepo", num: 2001 }), + expect(mockGraphqlResponse.mock.calls[1][0]).toContain("mutation"), + expect(mockGraphqlResponse.mock.calls[1][0]).toContain("addDiscussionComment"), + expect(mockGraphqlResponse.mock.calls[1][1].body).toContain("Test explicit discussion comment"), + expect(mockGraphqlResponse.mock.calls[1][1].replyToId).toBeUndefined(), + expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(), + expect(mockCore.setOutput).toHaveBeenCalledWith("comment_id", "DC_kwDOPc1QR84BpqRv"), + expect(mockCore.setOutput).toHaveBeenCalledWith("comment_url", "https://github.com/testowner/testrepo/discussions/2001#discussioncomment-456"), + expect(mockCore.info).toHaveBeenCalledWith("Creating comment on discussion #2001"), + delete process.env.GH_AW_COMMENT_TARGET, + delete process.env.GITHUB_AW_COMMENT_DISCUSSION, + delete global.github.graphql); + }), + it("should replace temporary ID references in comment body using the temporary ID map", async () => { + (setAgentOutput({ items: [{ type: "add_comment", body: "This comment references issue #aw_aabbccdd1122 which was created earlier." }] }), + (process.env.GH_AW_TEMPORARY_ID_MAP = JSON.stringify({ aw_aabbccdd1122: 456 })), + mockGithub.rest.issues.createComment.mockResolvedValue({ data: { id: 99999, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-99999" } }), + await eval(`(async () => { ${createCommentScript} })()`), + expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith(expect.objectContaining({ body: expect.stringContaining("#456") })), + expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith(expect.objectContaining({ body: expect.not.stringContaining("#aw_aabbccdd1122") })), + delete process.env.GH_AW_TEMPORARY_ID_MAP); + }), + it("should load temporary ID map and log the count", async () => { + (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment" }] }), + (process.env.GH_AW_TEMPORARY_ID_MAP = JSON.stringify({ aw_abc123: 100, aw_def456: 200 })), + mockGithub.rest.issues.createComment.mockResolvedValue({ data: { id: 99999, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-99999" } }), + await eval(`(async () => { ${createCommentScript} })()`), + expect(mockCore.info).toHaveBeenCalledWith("Loaded temporary ID map with 2 entries"), + delete process.env.GH_AW_TEMPORARY_ID_MAP); + }), + it("should handle empty temporary ID map gracefully", async () => { + (setAgentOutput({ items: [{ type: "add_comment", body: "Comment with #aw_000000000000 that won't be resolved" }] }), + (process.env.GH_AW_TEMPORARY_ID_MAP = "{}"), + mockGithub.rest.issues.createComment.mockResolvedValue({ data: { id: 99999, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-99999" } }), + await eval(`(async () => { ${createCommentScript} })()`), + expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith(expect.objectContaining({ body: expect.stringContaining("#aw_000000000000") })), + delete process.env.GH_AW_TEMPORARY_ID_MAP); + }), + it("should use custom footer message when GH_AW_SAFE_OUTPUT_MESSAGES is configured", async () => { + (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment with custom footer" }] }), + (process.env.GH_AW_WORKFLOW_NAME = "Custom Workflow"), + (process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ footer: "> Custom AI footer by [{workflow_name}]({run_url})", footerInstall: "> Custom install: `gh aw add {workflow_source}`" })), + (global.context.eventName = "issues"), + (global.context.payload.issue = { number: 456 })); + const mockComment = { id: 999, html_url: "https://github.com/testowner/testrepo/issues/456#issuecomment-999" }; + (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript} })()`)); + const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0]; + (expect(callArgs.body).toContain("Test comment with custom footer"), + expect(callArgs.body).toContain("Custom AI footer by [Custom Workflow]"), + expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345"), + expect(callArgs.body).not.toContain("Ahoy!"), + expect(callArgs.body).not.toContain("treasure was crafted"), + delete process.env.GH_AW_SAFE_OUTPUT_MESSAGES); + }), + it("should use custom footer with install instructions when workflow source is provided", async () => { + (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment with custom footer and install" }] }), + (process.env.GH_AW_WORKFLOW_NAME = "Custom Workflow"), + (process.env.GH_AW_WORKFLOW_SOURCE = "owner/repo/workflow.md@main"), + (process.env.GH_AW_WORKFLOW_SOURCE_URL = "https://github.com/owner/repo"), + (process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ footer: "> Generated by [{workflow_name}]({run_url})", footerInstall: "> Install: `gh aw add {workflow_source}`" })), + (global.context.eventName = "issues"), + (global.context.payload.issue = { number: 789 })); + const mockComment = { id: 1001, html_url: "https://github.com/testowner/testrepo/issues/789#issuecomment-1001" }; + (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript} })()`)); + const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0]; + (expect(callArgs.body).toContain("Test comment with custom footer and install"), + expect(callArgs.body).toContain("Generated by [Custom Workflow]"), + expect(callArgs.body).toContain("Install: `gh aw add owner/repo/workflow.md@main`"), + expect(callArgs.body).not.toContain("Ahoy!"), + expect(callArgs.body).not.toContain("plunder this workflow"), + delete process.env.GH_AW_SAFE_OUTPUT_MESSAGES, + delete process.env.GH_AW_WORKFLOW_SOURCE, + delete process.env.GH_AW_WORKFLOW_SOURCE_URL); + }), + it("should hide older comments when hide-older-comments is enabled", async () => { + (setAgentOutput({ items: [{ type: "add_comment", body: "New comment from workflow" }] }), + (process.env.GITHUB_WORKFLOW = "test-workflow-123"), + (process.env.GH_AW_HIDE_OLDER_COMMENTS = "true"), + (global.context.eventName = "issues"), + (global.context.payload.issue = { number: 100 }), + (mockGithub.rest.issues.listComments = vi.fn().mockResolvedValue({ + data: [ + { id: 1, node_id: "IC_oldcomment1", body: "Old comment 1\n\n\x3c!-- workflow-id: test-workflow-123 --\x3e" }, + { id: 2, node_id: "IC_oldcomment2", body: "Old comment 2\n\n\x3c!-- workflow-id: test-workflow-123 --\x3e" }, + { id: 3, node_id: "IC_othercomment", body: "Comment from different workflow" }, + ], + })), + (mockGithub.graphql = vi.fn().mockResolvedValue({ minimizeComment: { minimizedComment: { isMinimized: !0 } } }))); + const mockNewComment = { id: 4, html_url: "https://github.com/testowner/testrepo/issues/100#issuecomment-4" }; + (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }), + await eval(`(async () => { ${createCommentScript} })()`), + expect(mockGithub.rest.issues.listComments).toHaveBeenCalledWith({ owner: "testowner", repo: "testrepo", issue_number: 100, per_page: 100, page: 1 }), + expect(mockGithub.graphql).toHaveBeenCalledTimes(2), + expect(mockGithub.graphql).toHaveBeenCalledWith(expect.stringContaining("minimizeComment"), expect.objectContaining({ nodeId: "IC_oldcomment1", classifier: "OUTDATED" })), + expect(mockGithub.graphql).toHaveBeenCalledWith(expect.stringContaining("minimizeComment"), expect.objectContaining({ nodeId: "IC_oldcomment2", classifier: "OUTDATED" })), + expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith(expect.objectContaining({ owner: "testowner", repo: "testrepo", issue_number: 100, body: expect.stringContaining("New comment from workflow") })), + delete process.env.GITHUB_WORKFLOW, + delete process.env.GH_AW_HIDE_OLDER_COMMENTS); + }), + it("should not hide comments when hide-older-comments is not enabled", async () => { + (setAgentOutput({ items: [{ type: "add_comment", body: "New comment without hiding" }] }), + (process.env.GITHUB_WORKFLOW = "test-workflow-456"), + (global.context.eventName = "issues"), + (global.context.payload.issue = { number: 200 }), + (mockGithub.rest.issues.listComments = vi.fn()), + (mockGithub.graphql = vi.fn())); + const mockNewComment = { id: 5, html_url: "https://github.com/testowner/testrepo/issues/200#issuecomment-5" }; + (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }), + await eval(`(async () => { ${createCommentScript} })()`), + expect(mockGithub.rest.issues.listComments).not.toHaveBeenCalled(), + expect(mockGithub.graphql).not.toHaveBeenCalled(), + expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(), + delete process.env.GITHUB_WORKFLOW); + }), + it("should skip hiding when workflow-id is not available", async () => { + (setAgentOutput({ items: [{ type: "add_comment", body: "Comment without workflow-id" }] }), + (process.env.GH_AW_HIDE_OLDER_COMMENTS = "true"), + (global.context.eventName = "issues"), + (global.context.payload.issue = { number: 300 }), + (mockGithub.rest.issues.listComments = vi.fn()), + (mockGithub.graphql = vi.fn())); + const mockNewComment = { id: 6, html_url: "https://github.com/testowner/testrepo/issues/300#issuecomment-6" }; + (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }), + await eval(`(async () => { ${createCommentScript} })()`), + expect(mockGithub.rest.issues.listComments).not.toHaveBeenCalled(), + expect(mockGithub.graphql).not.toHaveBeenCalled(), + expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(), + delete process.env.GH_AW_HIDE_OLDER_COMMENTS); + }), + it("should respect allowed-reasons when hiding comments", async () => { + (setAgentOutput({ items: [{ type: "add_comment", body: "New comment with allowed reasons" }] }), + (process.env.GITHUB_WORKFLOW = "test-workflow-789"), + (process.env.GH_AW_HIDE_OLDER_COMMENTS = "true"), + (process.env.GH_AW_ALLOWED_REASONS = JSON.stringify(["OUTDATED", "RESOLVED"])), + (global.context.eventName = "issues"), + (global.context.payload.issue = { number: 400 }), + (mockGithub.rest.issues.listComments = vi.fn().mockResolvedValue({ data: [{ id: 1, node_id: "IC_oldcomment1", body: "Old comment\n\n\x3c!-- workflow-id: test-workflow-789 --\x3e" }] })), + (mockGithub.graphql = vi.fn().mockResolvedValue({ minimizeComment: { minimizedComment: { isMinimized: !0 } } }))); + const mockNewComment = { id: 2, html_url: "https://github.com/testowner/testrepo/issues/400#issuecomment-2" }; + (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }), + await eval(`(async () => { ${createCommentScript} })()`), + expect(mockGithub.graphql).toHaveBeenCalledWith(expect.stringContaining("minimizeComment"), expect.objectContaining({ nodeId: "IC_oldcomment1", classifier: "OUTDATED" })), + delete process.env.GITHUB_WORKFLOW, + delete process.env.GH_AW_HIDE_OLDER_COMMENTS, + delete process.env.GH_AW_ALLOWED_REASONS); + }), + it("should skip hiding when reason is not in allowed-reasons", async () => { + (setAgentOutput({ items: [{ type: "add_comment", body: "New comment with restricted reasons" }] }), + (process.env.GITHUB_WORKFLOW = "test-workflow-999"), + (process.env.GH_AW_HIDE_OLDER_COMMENTS = "true"), + (process.env.GH_AW_ALLOWED_REASONS = JSON.stringify(["SPAM"])), + (global.context.eventName = "issues"), + (global.context.payload.issue = { number: 500 }), + (mockGithub.rest.issues.listComments = vi.fn()), + (mockGithub.graphql = vi.fn())); + const mockNewComment = { id: 3, html_url: "https://github.com/testowner/testrepo/issues/500#issuecomment-3" }; + (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }), + await eval(`(async () => { ${createCommentScript} })()`), + expect(mockGithub.rest.issues.listComments).not.toHaveBeenCalled(), + expect(mockGithub.graphql).not.toHaveBeenCalled(), + expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(), + delete process.env.GITHUB_WORKFLOW, + delete process.env.GH_AW_HIDE_OLDER_COMMENTS, + delete process.env.GH_AW_ALLOWED_REASONS); + }), + it("should support lowercase allowed-reasons", async () => { + (setAgentOutput({ items: [{ type: "add_comment", body: "New comment with lowercase reasons" }] }), + (process.env.GITHUB_WORKFLOW = "test-workflow-lowercase"), + (process.env.GH_AW_HIDE_OLDER_COMMENTS = "true"), + (process.env.GH_AW_ALLOWED_REASONS = JSON.stringify(["outdated", "resolved"])), + (global.context.eventName = "issues"), + (global.context.payload.issue = { number: 600 }), + (mockGithub.rest.issues.listComments = vi.fn().mockResolvedValue({ data: [{ id: 1, node_id: "IC_oldcomment1", body: "Old comment\n\n\x3c!-- workflow-id: test-workflow-lowercase --\x3e" }] })), + (mockGithub.graphql = vi.fn().mockResolvedValue({ minimizeComment: { minimizedComment: { isMinimized: !0 } } }))); + const mockNewComment = { id: 4, html_url: "https://github.com/testowner/testrepo/issues/600#issuecomment-4" }; + (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }), + await eval(`(async () => { ${createCommentScript} })()`), + expect(mockGithub.graphql).toHaveBeenCalledWith(expect.stringContaining("minimizeComment"), expect.objectContaining({ nodeId: "IC_oldcomment1", classifier: "OUTDATED" })), + delete process.env.GITHUB_WORKFLOW, + delete process.env.GH_AW_HIDE_OLDER_COMMENTS, + delete process.env.GH_AW_ALLOWED_REASONS); + })); + })); diff --git a/pkg/workflow/js/add_copilot_reviewer.cjs b/pkg/workflow/js/add_copilot_reviewer.cjs index 40281e4bd8d..d7bced581f9 100644 --- a/pkg/workflow/js/add_copilot_reviewer.cjs +++ b/pkg/workflow/js/add_copilot_reviewer.cjs @@ -1,63 +1,21 @@ -// @ts-check -/// - -/** - * Add Copilot as a reviewer to a pull request. - * - * This script is used to add the GitHub Copilot pull request reviewer bot - * to a pull request. It uses the `github` object from actions/github-script - * instead of the `gh api` CLI command. - * - * Environment variables: - * - PR_NUMBER: The pull request number to add the reviewer to - */ - -// GitHub Copilot reviewer bot username const COPILOT_REVIEWER_BOT = "copilot-pull-request-reviewer[bot]"; - async function main() { - // Validate required environment variables const prNumberStr = process.env.PR_NUMBER; - - if (!prNumberStr || prNumberStr.trim() === "") { - core.setFailed("PR_NUMBER environment variable is required but not set"); - return; - } - + if (!prNumberStr || "" === prNumberStr.trim()) return void core.setFailed("PR_NUMBER environment variable is required but not set"); const prNumber = parseInt(prNumberStr.trim(), 10); - if (isNaN(prNumber) || prNumber <= 0) { - core.setFailed(`Invalid PR_NUMBER: ${prNumberStr}. Must be a positive integer.`); - return; - } - - core.info(`Adding Copilot as reviewer to PR #${prNumber}`); - - try { - await github.rest.pulls.requestReviewers({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - reviewers: [COPILOT_REVIEWER_BOT], - }); - - core.info(`Successfully added Copilot as reviewer to PR #${prNumber}`); - - await core.summary - .addRaw( - ` -## Copilot Reviewer Added - -Successfully added Copilot as a reviewer to PR #${prNumber}. -` - ) - .write(); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - core.error(`Failed to add Copilot as reviewer: ${errorMessage}`); - core.setFailed(`Failed to add Copilot as reviewer to PR #${prNumber}: ${errorMessage}`); + if (isNaN(prNumber) || prNumber <= 0) core.setFailed(`Invalid PR_NUMBER: ${prNumberStr}. Must be a positive integer.`); + else { + core.info(`Adding Copilot as reviewer to PR #${prNumber}`); + try { + (await github.rest.pulls.requestReviewers({ owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber, reviewers: [COPILOT_REVIEWER_BOT] }), + core.info(`Successfully added Copilot as reviewer to PR #${prNumber}`), + await core.summary.addRaw(`\n## Copilot Reviewer Added\n\nSuccessfully added Copilot as a reviewer to PR #${prNumber}.\n`).write()); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + (core.error(`Failed to add Copilot as reviewer: ${errorMessage}`), core.setFailed(`Failed to add Copilot as reviewer to PR #${prNumber}: ${errorMessage}`)); + } } } - main().catch(error => { core.setFailed(error instanceof Error ? error.message : String(error)); }); diff --git a/pkg/workflow/js/add_copilot_reviewer.test.cjs b/pkg/workflow/js/add_copilot_reviewer.test.cjs index d2ef7af9288..464deea5d93 100644 --- a/pkg/workflow/js/add_copilot_reviewer.test.cjs +++ b/pkg/workflow/js/add_copilot_reviewer.test.cjs @@ -1,151 +1,62 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; - -// Mock the global objects that GitHub Actions provides -const mockCore = { - debug: vi.fn(), - info: vi.fn(), - notice: vi.fn(), - warning: vi.fn(), - error: vi.fn(), - setFailed: vi.fn(), - setOutput: vi.fn(), - summary: { - addRaw: vi.fn().mockReturnThis(), - write: vi.fn().mockResolvedValue(), - }, -}; - -const mockGithub = { - rest: { - pulls: { - requestReviewers: vi.fn().mockResolvedValue({}), - }, - }, -}; - -const mockContext = { - eventName: "pull_request", - repo: { - owner: "testowner", - repo: "testrepo", - }, - payload: { - pull_request: { - number: 123, - }, - }, -}; - -// Set up global mocks before importing the module -global.core = mockCore; -global.github = mockGithub; -global.context = mockContext; - -describe("add_copilot_reviewer", () => { - beforeEach(() => { - // Reset all mocks before each test - vi.clearAllMocks(); - vi.resetModules(); // Reset module cache to allow fresh imports - - // Clear environment variables - delete process.env.PR_NUMBER; - - // Reset context to default - global.context = { - eventName: "pull_request", - repo: { - owner: "testowner", - repo: "testrepo", - }, - payload: { - pull_request: { - number: 123, - }, - }, - }; - }); - - it("should fail when PR_NUMBER is not set", async () => { - delete process.env.PR_NUMBER; - - await import("./add_copilot_reviewer.cjs"); - - expect(mockCore.setFailed).toHaveBeenCalledWith("PR_NUMBER environment variable is required but not set"); - expect(mockGithub.rest.pulls.requestReviewers).not.toHaveBeenCalled(); - }); - - it("should fail when PR_NUMBER is empty", async () => { - process.env.PR_NUMBER = " "; - - await import("./add_copilot_reviewer.cjs"); - - expect(mockCore.setFailed).toHaveBeenCalledWith("PR_NUMBER environment variable is required but not set"); - expect(mockGithub.rest.pulls.requestReviewers).not.toHaveBeenCalled(); - }); - - it("should fail when PR_NUMBER is not a valid number", async () => { - process.env.PR_NUMBER = "not-a-number"; - - await import("./add_copilot_reviewer.cjs"); - - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Invalid PR_NUMBER")); - expect(mockGithub.rest.pulls.requestReviewers).not.toHaveBeenCalled(); - }); - - it("should fail when PR_NUMBER is zero", async () => { - process.env.PR_NUMBER = "0"; - - await import("./add_copilot_reviewer.cjs"); - - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Invalid PR_NUMBER")); - expect(mockGithub.rest.pulls.requestReviewers).not.toHaveBeenCalled(); - }); - - it("should fail when PR_NUMBER is negative", async () => { - process.env.PR_NUMBER = "-1"; - - await import("./add_copilot_reviewer.cjs"); - - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Invalid PR_NUMBER")); - expect(mockGithub.rest.pulls.requestReviewers).not.toHaveBeenCalled(); - }); - - it("should add copilot as reviewer when PR_NUMBER is valid", async () => { - process.env.PR_NUMBER = "456"; - - await import("./add_copilot_reviewer.cjs"); - - expect(mockGithub.rest.pulls.requestReviewers).toHaveBeenCalledWith({ - owner: "testowner", - repo: "testrepo", - pull_number: 456, - reviewers: ["copilot-pull-request-reviewer[bot]"], - }); - expect(mockCore.info).toHaveBeenCalledWith("Successfully added Copilot as reviewer to PR #456"); - expect(mockCore.summary.addRaw).toHaveBeenCalled(); - expect(mockCore.summary.write).toHaveBeenCalled(); - }); - - it("should handle API errors gracefully", async () => { - process.env.PR_NUMBER = "123"; - mockGithub.rest.pulls.requestReviewers.mockRejectedValueOnce(new Error("API Error")); - - await import("./add_copilot_reviewer.cjs"); - - expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to add Copilot as reviewer")); - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Failed to add Copilot as reviewer")); - }); - - it("should trim whitespace from PR_NUMBER", async () => { - process.env.PR_NUMBER = " 789 "; - - await import("./add_copilot_reviewer.cjs"); - - expect(mockGithub.rest.pulls.requestReviewers).toHaveBeenCalledWith({ - owner: "testowner", - repo: "testrepo", - pull_number: 789, - reviewers: ["copilot-pull-request-reviewer[bot]"], - }); - }); -}); +const mockCore = { debug: vi.fn(), info: vi.fn(), notice: vi.fn(), warning: vi.fn(), error: vi.fn(), setFailed: vi.fn(), setOutput: vi.fn(), summary: { addRaw: vi.fn().mockReturnThis(), write: vi.fn().mockResolvedValue() } }, + mockGithub = { rest: { pulls: { requestReviewers: vi.fn().mockResolvedValue({}) } } }, + mockContext = { eventName: "pull_request", repo: { owner: "testowner", repo: "testrepo" }, payload: { pull_request: { number: 123 } } }; +((global.core = mockCore), + (global.github = mockGithub), + (global.context = mockContext), + describe("add_copilot_reviewer", () => { + (beforeEach(() => { + (vi.clearAllMocks(), vi.resetModules(), delete process.env.PR_NUMBER, (global.context = { eventName: "pull_request", repo: { owner: "testowner", repo: "testrepo" }, payload: { pull_request: { number: 123 } } })); + }), + it("should fail when PR_NUMBER is not set", async () => { + (delete process.env.PR_NUMBER, + await import("./add_copilot_reviewer.cjs"), + expect(mockCore.setFailed).toHaveBeenCalledWith("PR_NUMBER environment variable is required but not set"), + expect(mockGithub.rest.pulls.requestReviewers).not.toHaveBeenCalled()); + }), + it("should fail when PR_NUMBER is empty", async () => { + ((process.env.PR_NUMBER = " "), + await import("./add_copilot_reviewer.cjs"), + expect(mockCore.setFailed).toHaveBeenCalledWith("PR_NUMBER environment variable is required but not set"), + expect(mockGithub.rest.pulls.requestReviewers).not.toHaveBeenCalled()); + }), + it("should fail when PR_NUMBER is not a valid number", async () => { + ((process.env.PR_NUMBER = "not-a-number"), + await import("./add_copilot_reviewer.cjs"), + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Invalid PR_NUMBER")), + expect(mockGithub.rest.pulls.requestReviewers).not.toHaveBeenCalled()); + }), + it("should fail when PR_NUMBER is zero", async () => { + ((process.env.PR_NUMBER = "0"), + await import("./add_copilot_reviewer.cjs"), + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Invalid PR_NUMBER")), + expect(mockGithub.rest.pulls.requestReviewers).not.toHaveBeenCalled()); + }), + it("should fail when PR_NUMBER is negative", async () => { + ((process.env.PR_NUMBER = "-1"), + await import("./add_copilot_reviewer.cjs"), + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Invalid PR_NUMBER")), + expect(mockGithub.rest.pulls.requestReviewers).not.toHaveBeenCalled()); + }), + it("should add copilot as reviewer when PR_NUMBER is valid", async () => { + ((process.env.PR_NUMBER = "456"), + await import("./add_copilot_reviewer.cjs"), + expect(mockGithub.rest.pulls.requestReviewers).toHaveBeenCalledWith({ owner: "testowner", repo: "testrepo", pull_number: 456, reviewers: ["copilot-pull-request-reviewer[bot]"] }), + expect(mockCore.info).toHaveBeenCalledWith("Successfully added Copilot as reviewer to PR #456"), + expect(mockCore.summary.addRaw).toHaveBeenCalled(), + expect(mockCore.summary.write).toHaveBeenCalled()); + }), + it("should handle API errors gracefully", async () => { + ((process.env.PR_NUMBER = "123"), + mockGithub.rest.pulls.requestReviewers.mockRejectedValueOnce(new Error("API Error")), + await import("./add_copilot_reviewer.cjs"), + expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to add Copilot as reviewer")), + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Failed to add Copilot as reviewer"))); + }), + it("should trim whitespace from PR_NUMBER", async () => { + ((process.env.PR_NUMBER = " 789 "), + await import("./add_copilot_reviewer.cjs"), + expect(mockGithub.rest.pulls.requestReviewers).toHaveBeenCalledWith({ owner: "testowner", repo: "testrepo", pull_number: 789, reviewers: ["copilot-pull-request-reviewer[bot]"] })); + })); + })); diff --git a/pkg/workflow/js/add_labels.test.cjs b/pkg/workflow/js/add_labels.test.cjs index 066f535208b..f750206e14f 100644 --- a/pkg/workflow/js/add_labels.test.cjs +++ b/pkg/workflow/js/add_labels.test.cjs @@ -1,1088 +1,436 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import fs from "fs"; import path from "path"; - -// Mock the global objects that GitHub Actions provides const mockCore = { - // Core logging functions - debug: vi.fn(), - info: vi.fn(), - notice: vi.fn(), - warning: vi.fn(), - error: vi.fn(), - - // Core workflow functions - setFailed: vi.fn(), - setOutput: vi.fn(), - exportVariable: vi.fn(), - setSecret: vi.fn(), - - // Input/state functions (less commonly used but included for completeness) - getInput: vi.fn(), - getBooleanInput: vi.fn(), - getMultilineInput: vi.fn(), - getState: vi.fn(), - saveState: vi.fn(), - - // Group functions - startGroup: vi.fn(), - endGroup: vi.fn(), - group: vi.fn(), - - // Other utility functions - addPath: vi.fn(), - setCommandEcho: vi.fn(), - isDebug: vi.fn().mockReturnValue(false), - getIDToken: vi.fn(), - toPlatformPath: vi.fn(), - toPosixPath: vi.fn(), - toWin32Path: vi.fn(), - - // Summary object with chainable methods - summary: { - addRaw: vi.fn().mockReturnThis(), - write: vi.fn().mockResolvedValue(), + debug: vi.fn(), + info: vi.fn(), + notice: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), + setOutput: vi.fn(), + exportVariable: vi.fn(), + setSecret: vi.fn(), + getInput: vi.fn(), + getBooleanInput: vi.fn(), + getMultilineInput: vi.fn(), + getState: vi.fn(), + saveState: vi.fn(), + startGroup: vi.fn(), + endGroup: vi.fn(), + group: vi.fn(), + addPath: vi.fn(), + setCommandEcho: vi.fn(), + isDebug: vi.fn().mockReturnValue(!1), + getIDToken: vi.fn(), + toPlatformPath: vi.fn(), + toPosixPath: vi.fn(), + toWin32Path: vi.fn(), + summary: { addRaw: vi.fn().mockReturnThis(), write: vi.fn().mockResolvedValue() }, }, -}; - -const mockGithub = { - rest: { - issues: { - addLabels: vi.fn(), - }, - }, -}; - -const mockContext = { - eventName: "issues", - repo: { - owner: "testowner", - repo: "testrepo", - }, - payload: { - issue: { - number: 123, - }, - }, -}; - -// Set up global variables -global.core = mockCore; -global.github = mockGithub; -global.context = mockContext; - -describe("add_labels.cjs", () => { - let addLabelsScript; - - let tempFilePath; - - // Helper function to set agent output via file - const setAgentOutput = data => { - tempFilePath = path.join("/tmp", `test_agent_output_${Date.now()}_${Math.random().toString(36).slice(2)}.json`); - const content = typeof data === "string" ? data : JSON.stringify(data); - fs.writeFileSync(tempFilePath, content); - process.env.GH_AW_AGENT_OUTPUT = tempFilePath; - }; - - beforeEach(() => { - // Reset all mocks - vi.clearAllMocks(); - - // Reset environment variables - delete process.env.GH_AW_AGENT_OUTPUT; - delete process.env.GH_AW_LABELS_ALLOWED; - delete process.env.GH_AW_LABELS_MAX_COUNT; - - // Reset context to default state - global.context.eventName = "issues"; - global.context.payload.issue = { number: 123 }; - delete global.context.payload.pull_request; - - // Read the script content - const scriptPath = path.join(process.cwd(), "add_labels.cjs"); - addLabelsScript = fs.readFileSync(scriptPath, "utf8"); - }); - - afterEach(() => { - // Clean up temporary file - if (tempFilePath && require("fs").existsSync(tempFilePath)) { - require("fs").unlinkSync(tempFilePath); - tempFilePath = undefined; - } - }); - - describe("Environment variable validation", () => { - it("should skip when no agent output is provided", async () => { - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement"; - delete process.env.GH_AW_AGENT_OUTPUT; - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockCore.info).toHaveBeenCalledWith("No GH_AW_AGENT_OUTPUT environment variable found"); - expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); - }); - - it("should skip when agent output is empty", async () => { - setAgentOutput(""); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement"; - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockCore.info).toHaveBeenCalledWith("Agent output content is empty"); - expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); - }); - - it("should work when allowed labels are not provided (any labels allowed)", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug", "enhancement", "custom-label"], - }, - ], - }); - delete process.env.GH_AW_LABELS_ALLOWED; - process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test label processing - - mockGithub.rest.issues.addLabels.mockResolvedValue({}); - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockCore.info).toHaveBeenCalledWith("No label addition restrictions - any label additions are allowed"); - expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ - owner: "testowner", - repo: "testrepo", - issue_number: 123, - labels: ["bug", "enhancement", "custom-label"], - }); - }); - - it("should work when allowed labels list is empty (any labels allowed)", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug", "enhancement", "custom-label"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = " "; - process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test label processing - - mockGithub.rest.issues.addLabels.mockResolvedValue({}); - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockCore.info).toHaveBeenCalledWith("No label addition restrictions - any label additions are allowed"); - expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ - owner: "testowner", - repo: "testrepo", - issue_number: 123, - labels: ["bug", "enhancement", "custom-label"], - }); - }); - - it("should enforce allowed labels when restrictions are set", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug", "enhancement", "custom-label", "documentation"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement"; - process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test label filtering - - mockGithub.rest.issues.addLabels.mockResolvedValue({}); - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockCore.info).toHaveBeenCalledWith(`Allowed label additions: ${JSON.stringify(["bug", "enhancement"])}`); - expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ - owner: "testowner", - repo: "testrepo", - issue_number: 123, - labels: ["bug", "enhancement"], // 'custom-label' and 'documentation' filtered out - }); - }); - - it("should fail when max count is invalid", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug", "enhancement"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement"; - process.env.GH_AW_LABELS_MAX_COUNT = "invalid"; - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockCore.setFailed).toHaveBeenCalledWith("Invalid max value: invalid. Must be a positive integer"); - expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); - }); - - it("should fail when max count is zero", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug", "enhancement"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement"; - process.env.GH_AW_LABELS_MAX_COUNT = "0"; - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockCore.setFailed).toHaveBeenCalledWith("Invalid max value: 0. Must be a positive integer"); - expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); - }); - - it("should use default max count when not specified", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug", "enhancement", "feature", "documentation"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement,feature,documentation"; - delete process.env.GH_AW_LABELS_MAX_COUNT; - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockCore.info).toHaveBeenCalledWith("Max count: 1"); - expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ - owner: "testowner", - repo: "testrepo", - issue_number: 123, - labels: ["bug"], // Only first 1 due to default max count - }); - }); - }); - - describe("Context validation", () => { - it("should skip when not in issue or PR context (with default target)", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug", "enhancement"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement"; - global.context.eventName = "push"; - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockCore.info).toHaveBeenCalledWith('Target is "triggering" but not running in issue or pull request context, skipping label addition'); - expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); - }); - - it("should work with issue_comment event", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement"; - global.context.eventName = "issue_comment"; - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockGithub.rest.issues.addLabels).toHaveBeenCalled(); - }); - - it("should work with pull_request event", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement"; - global.context.eventName = "pull_request"; - global.context.payload.pull_request = { number: 456 }; - delete global.context.payload.issue; - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ - owner: "testowner", - repo: "testrepo", - issue_number: 456, - labels: ["bug"], - }); - }); - - it("should work with pull_request_review event", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement"; - global.context.eventName = "pull_request_review"; - global.context.payload.pull_request = { number: 789 }; - delete global.context.payload.issue; - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ - owner: "testowner", - repo: "testrepo", - issue_number: 789, - labels: ["bug"], - }); - }); - - it("should fail when issue context detected but no issue in payload", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement"; - global.context.eventName = "issues"; - delete global.context.payload.issue; - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockCore.setFailed).toHaveBeenCalledWith("Issue context detected but no issue found in payload"); - expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); - }); - - it("should fail when PR context detected but no PR in payload", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement"; - global.context.eventName = "pull_request"; - delete global.context.payload.issue; - delete global.context.payload.pull_request; - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockCore.setFailed).toHaveBeenCalledWith("Pull request context detected but no pull request found in payload"); - expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); - }); - }); - - describe("Label parsing and validation", () => { - it("should parse labels from agent output and add valid ones", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug", "enhancement", "documentation"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement,feature"; - process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test label filtering - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ - owner: "testowner", - repo: "testrepo", - issue_number: 123, - labels: ["bug", "enhancement"], // 'documentation' not in allowed list - }); - - expect(mockCore.setOutput).toHaveBeenCalledWith("labels_added", "bug\nenhancement"); - expect(mockCore.summary.addRaw).toHaveBeenCalled(); - expect(mockCore.summary.write).toHaveBeenCalled(); - }); - - it("should skip empty lines in agent output", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug", "enhancement"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement"; - process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test label processing - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ - owner: "testowner", - repo: "testrepo", - issue_number: 123, - labels: ["bug", "enhancement"], - }); - }); - - it("should fail when line starts with dash (removal indication)", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug", "-enhancement"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement"; - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockCore.setFailed).toHaveBeenCalledWith("Label removal is not permitted. Found line starting with '-': -enhancement"); - expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); - }); - - it("should remove duplicate labels", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug", "enhancement", "bug", "enhancement"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement"; - process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test deduplication - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ - owner: "testowner", - repo: "testrepo", - issue_number: 123, - labels: ["bug", "enhancement"], // Duplicates removed - }); - }); - - it("should enforce max count limit", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug", "enhancement", "feature", "documentation", "question"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement,feature,documentation,question"; - process.env.GH_AW_LABELS_MAX_COUNT = "2"; - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockCore.info).toHaveBeenCalledWith("Too many labels (5), limiting to 2"); - expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ - owner: "testowner", - repo: "testrepo", - issue_number: 123, - labels: ["bug", "enhancement"], // Only first 2 - }); - }); - - it("should skip when no valid labels found", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["invalid", "another-invalid"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement"; - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockCore.info).toHaveBeenCalledWith("No labels to add"); - expect(mockCore.setOutput).toHaveBeenCalledWith("labels_added", ""); - expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("No labels were added")); - expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled(); - }); - }); - - describe("GitHub API integration", () => { - it("should successfully add labels to issue", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug", "enhancement"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement,feature"; - process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test label addition - - mockGithub.rest.issues.addLabels.mockResolvedValue({}); - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ - owner: "testowner", - repo: "testrepo", - issue_number: 123, - labels: ["bug", "enhancement"], - }); - - expect(mockCore.info).toHaveBeenCalledWith("Successfully added 2 labels to issue #123"); - expect(mockCore.setOutput).toHaveBeenCalledWith("labels_added", "bug\nenhancement"); - - const summaryCall = mockCore.summary.addRaw.mock.calls.find(call => call[0].includes("Successfully added 2 label(s) to issue #123")); - expect(summaryCall).toBeDefined(); - expect(summaryCall[0]).toContain("- `bug`"); - expect(summaryCall[0]).toContain("- `enhancement`"); - }); - - it("should successfully add labels to pull request", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement"; - global.context.eventName = "pull_request"; - global.context.payload.pull_request = { number: 456 }; - delete global.context.payload.issue; - - mockGithub.rest.issues.addLabels.mockResolvedValue({}); - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockCore.info).toHaveBeenCalledWith("Successfully added 1 labels to pull request #456"); - - const summaryCall = mockCore.summary.addRaw.mock.calls.find(call => call[0].includes("Successfully added 1 label(s) to pull request #456")); - expect(summaryCall).toBeDefined(); - }); - - it("should handle GitHub API errors", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement"; - - const apiError = new Error("Label does not exist"); - mockGithub.rest.issues.addLabels.mockRejectedValue(apiError); - - const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockCore.error).toHaveBeenCalledWith("Failed to add labels: Label does not exist"); - expect(mockCore.setFailed).toHaveBeenCalledWith("Failed to add labels: Label does not exist"); - }); - - it("should handle non-Error objects in catch block", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement"; - - const stringError = "Something went wrong"; - mockGithub.rest.issues.addLabels.mockRejectedValue(stringError); - - const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockCore.error).toHaveBeenCalledWith("Failed to add labels: Something went wrong"); - expect(mockCore.setFailed).toHaveBeenCalledWith("Failed to add labels: Something went wrong"); - }); - }); - - describe("Output and logging", () => { - it("should log agent output content length", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug", "enhancement"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement"; - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockCore.info).toHaveBeenCalledWith("Agent output content length: 64"); - }); - - it("should log allowed labels and max count", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement,feature"; - process.env.GH_AW_LABELS_MAX_COUNT = "5"; - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockCore.info).toHaveBeenCalledWith(`Allowed label additions: ${JSON.stringify(["bug", "enhancement", "feature"])}`); - expect(mockCore.info).toHaveBeenCalledWith("Max count: 5"); - }); - - it("should log requested labels", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug", "enhancement", "invalid"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement"; - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockCore.info).toHaveBeenCalledWith(`Requested labels: ${JSON.stringify(["bug", "enhancement", "invalid"])}`); - }); - - it("should log final labels being added", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug", "enhancement"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement"; - process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test logging - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockCore.info).toHaveBeenCalledWith(`Adding 2 labels to issue #123: ${JSON.stringify(["bug", "enhancement"])}`); - }); - }); - - describe("Edge cases", () => { - it("should handle whitespace in allowed labels", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug", "enhancement"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = " bug , enhancement , feature "; - process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test label processing - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockCore.info).toHaveBeenCalledWith(`Allowed label additions: ${JSON.stringify(["bug", "enhancement", "feature"])}`); - expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ - owner: "testowner", - repo: "testrepo", - issue_number: 123, - labels: ["bug", "enhancement"], - }); - }); - - it("should handle empty entries in allowed labels", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,,enhancement,"; - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockCore.info).toHaveBeenCalledWith(`Allowed label additions: ${JSON.stringify(["bug", "enhancement"])}`); - }); - - it("should handle single label output", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug"], - }, - ], - }); - process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement"; - - mockGithub.rest.issues.addLabels.mockResolvedValue({}); - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ - owner: "testowner", - repo: "testrepo", - issue_number: 123, - labels: ["bug"], - }); - - expect(mockCore.setOutput).toHaveBeenCalledWith("labels_added", "bug"); - }); - - it("should handle duplicate labels by removing duplicates", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug", "enhancement", "bug", "automation", "enhancement"], - }, - ], - }); - process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test deduplication - - mockGithub.rest.issues.addLabels.mockResolvedValue({}); - - // Execute the script - await eval(`(async () => { ${addLabelsScript} })()`); - - expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ - owner: "testowner", - repo: "testrepo", - issue_number: 123, - labels: ["bug", "enhancement", "automation"], - }); - }); - - it("should sanitize labels by removing problematic characters", async () => { - setAgentOutput({ - items: [ - { - type: "add_labels", - labels: ["bug]]>"); - expect(result).toBe("(![CDATA[(script)alert('xss')(/script)]])"); - }); - - it("should preserve inline formatting tags", () => { - const input = "This is bold, italic, and bold too text."; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - - it("should preserve list structure tags", () => { - const input = ""; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - - it("should preserve ordered list tags", () => { - const input = "
  1. First
  2. Second
"; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - - it("should preserve blockquote tags", () => { - const input = "
This is a quote
"; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - - it("should handle mixed allowed tags with formatting", () => { - const input = "

This is bold and italic text.
New line here.

"; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - - it("should handle nested list structure", () => { - const input = ""; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - - it("should preserve details and summary tags", () => { - const result1 = sanitizeContent("
content
"); - expect(result1).toBe("
content
"); - - const result2 = sanitizeContent("content"); - expect(result2).toBe("content"); - }); - - it("should convert removed tags that are no longer allowed", () => { - // Tag that was previously allowed but is now removed: u - const result3 = sanitizeContent("content"); - expect(result3).toBe("(u)content(/u)"); - }); - - it("should preserve heading tags h1-h6", () => { - const headings = ["h1", "h2", "h3", "h4", "h5", "h6"]; - headings.forEach(tag => { - const input = `<${tag}>Heading`; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - }); - - it("should preserve hr tag", () => { - const result = sanitizeContent("Content before
Content after"); - expect(result).toBe("Content before
Content after"); - }); - - it("should preserve pre tag", () => { - const input = "
Code block content
"; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - - it("should preserve sub and sup tags", () => { - const input1 = "H2O"; - const result1 = sanitizeContent(input1); - expect(result1).toBe(input1); - - const input2 = "E=mc2"; - const result2 = sanitizeContent(input2); - expect(result2).toBe(input2); - }); - - it("should preserve table structure tags", () => { - const input = "
Header
Data
"; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - }); - - describe("ANSI escape sequence removal", () => { - it("should remove ANSI color codes", () => { - const result = sanitizeContent("\x1b[31mred text\x1b[0m"); - expect(result).toBe("red text"); - }); - - it("should remove various ANSI codes", () => { - const result = sanitizeContent("\x1b[1;32mBold Green\x1b[0m"); - expect(result).toBe("Bold Green"); - }); - }); - - describe("control character removal", () => { - it("should remove control characters", () => { - const result = sanitizeContent("test\x00\x01\x02\x03content"); - expect(result).toBe("testcontent"); - }); - - it("should preserve newlines and tabs", () => { - const result = sanitizeContent("test\ncontent\twith\ttabs"); - expect(result).toBe("test\ncontent\twith\ttabs"); - }); - - it("should remove DEL character", () => { - const result = sanitizeContent("test\x7Fcontent"); - expect(result).toBe("testcontent"); - }); - }); - - describe("URL protocol sanitization", () => { - it("should allow HTTPS URLs", () => { - const result = sanitizeContent("Visit https://github.com"); - expect(result).toBe("Visit https://github.com"); - }); - - it("should redact HTTP URLs", () => { - const result = sanitizeContent("Visit http://example.com"); - expect(result).toContain("(redacted)"); - expect(mockCore.info).toHaveBeenCalled(); - }); - - it("should redact javascript: URLs", () => { - const result = sanitizeContent("Click javascript:alert('xss')"); - expect(result).toContain("(redacted)"); - }); - - it("should redact data: URLs", () => { - const result = sanitizeContent("Image data:image/png;base64,abc123"); - expect(result).toContain("(redacted)"); - }); - - it("should preserve file paths with colons", () => { - const result = sanitizeContent("C:\\path\\to\\file"); - expect(result).toBe("C:\\path\\to\\file"); - }); - - it("should preserve namespace patterns", () => { - const result = sanitizeContent("std::vector::push_back"); - expect(result).toBe("std::vector::push_back"); - }); - }); - - describe("URL domain filtering", () => { - it("should allow default GitHub domains", () => { - const urls = ["https://github.com/repo", "https://api.github.com/endpoint", "https://raw.githubusercontent.com/file", "https://example.github.io/page"]; - - urls.forEach(url => { - const result = sanitizeContent(`Visit ${url}`); - expect(result).toBe(`Visit ${url}`); - }); - }); - - it("should redact disallowed domains", () => { - const result = sanitizeContent("Visit https://evil.com/malicious"); - expect(result).toContain("(redacted)"); - expect(mockCore.info).toHaveBeenCalled(); - }); - - it("should use custom allowed domains from environment", () => { - process.env.GH_AW_ALLOWED_DOMAINS = "example.com,trusted.net"; - const result = sanitizeContent("Visit https://example.com/page"); - expect(result).toBe("Visit https://example.com/page"); - }); - - it("should extract and allow GitHub Enterprise domains", () => { - process.env.GITHUB_SERVER_URL = "https://github.company.com"; - const result = sanitizeContent("Visit https://github.company.com/repo"); - expect(result).toBe("Visit https://github.company.com/repo"); - }); - - it("should allow subdomains of allowed domains", () => { - const result = sanitizeContent("Visit https://subdomain.github.com/page"); - expect(result).toBe("Visit https://subdomain.github.com/page"); - }); - - it("should log redacted domains", () => { - sanitizeContent("Visit https://verylongdomainnamefortest.com/page"); - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Redacted URL:")); - expect(mockCore.debug).toHaveBeenCalledWith(expect.stringContaining("Redacted URL (full):")); - }); - }); - - describe("bot trigger neutralization", () => { - it("should neutralize 'fixes #123' patterns", () => { - const result = sanitizeContent("This fixes #123"); - expect(result).toBe("This `fixes #123`"); - }); - - it("should neutralize 'closes #456' patterns", () => { - const result = sanitizeContent("PR closes #456"); - expect(result).toBe("PR `closes #456`"); - }); - - it("should neutralize 'resolves #789' patterns", () => { - const result = sanitizeContent("This resolves #789"); - expect(result).toBe("This `resolves #789`"); - }); - - it("should handle various bot trigger verbs", () => { - const triggers = ["fix", "fixes", "close", "closes", "resolve", "resolves"]; - triggers.forEach(verb => { - const result = sanitizeContent(`This ${verb} #123`); - expect(result).toBe(`This \`${verb} #123\``); - }); - }); - - it("should neutralize alphanumeric issue references", () => { - const result = sanitizeContent("fixes #abc123def"); - expect(result).toBe("`fixes #abc123def`"); - }); - }); - - describe("content truncation", () => { - it("should truncate content exceeding max length", () => { - const longContent = "x".repeat(600000); - const result = sanitizeContent(longContent); - - expect(result.length).toBeLessThan(longContent.length); - expect(result).toContain("[Content truncated due to length]"); - }); - - it("should truncate content exceeding max lines", () => { - const manyLines = Array(70000).fill("line").join("\n"); - const result = sanitizeContent(manyLines); - - expect(result.split("\n").length).toBeLessThan(70000); - expect(result).toContain("[Content truncated due to line count]"); - }); - - it("should respect custom max length parameter", () => { - const content = "x".repeat(200); - const result = sanitizeContent(content, 100); - - expect(result.length).toBeLessThanOrEqual(100 + 50); // +50 for truncation message - expect(result).toContain("[Content truncated"); - }); - - it("should not truncate short content", () => { - const shortContent = "This is a short message"; - const result = sanitizeContent(shortContent); - - expect(result).toBe(shortContent); - expect(result).not.toContain("[Content truncated"); - }); - }); - - describe("combined sanitization", () => { - it("should apply all sanitizations correctly", () => { - const input = ` - - Hello @user, visit https://github.com - - This fixes #123 - \x1b[31mRed text\x1b[0m - `; - - const result = sanitizeContent(input); - - expect(result).not.toContain(""); - expect(result).toContain("`@user`"); - expect(result).toContain("https://github.com"); - expect(result).not.toContain(""]; - - maliciousInputs.forEach(input => { - const result = sanitizeContent(input); - expect(result).not.toContain(" { - const input = "
Header
Data
"; - const result = sanitizeContent(input); - - expect(result).toBe(input); - }); - }); - - describe("edge cases", () => { - it("should handle empty string", () => { - expect(sanitizeContent("")).toBe(""); - }); - - it("should handle whitespace-only input", () => { - expect(sanitizeContent(" \n\t ")).toBe(""); - }); - - it("should handle content with only control characters", () => { - const result = sanitizeContent("\x00\x01\x02\x03"); - expect(result).toBe(""); - }); - - it("should handle content with multiple consecutive spaces", () => { - const result = sanitizeContent("hello world"); - expect(result).toBe("hello world"); - }); - - it("should handle Unicode characters", () => { - const result = sanitizeContent("Hello 世界 🌍"); - expect(result).toBe("Hello 世界 🌍"); - }); - - it("should handle URLs in query parameters", () => { - const input = "https://github.com/redirect?url=https://github.com/target"; - const result = sanitizeContent(input); - - expect(result).toContain("github.com"); - expect(result).not.toContain("(redacted)"); - }); - - it("should handle nested backticks", () => { - const result = sanitizeContent("Already `@user` and @other"); - expect(result).toBe("Already `@user` and `@other`"); - }); - }); - - describe("redacted domains collection", () => { - let getRedactedDomains; - let clearRedactedDomains; - let writeRedactedDomainsLog; - const fs = require("fs"); - const path = require("path"); - - beforeEach(async () => { - const module = await import("./sanitize_content.cjs"); - getRedactedDomains = module.getRedactedDomains; - clearRedactedDomains = module.clearRedactedDomains; - writeRedactedDomainsLog = module.writeRedactedDomainsLog; - // Clear collected domains before each test - clearRedactedDomains(); - }); - - it("should collect redacted HTTPS domains", () => { - sanitizeContent("Visit https://evil.com/malware"); - const domains = getRedactedDomains(); - expect(domains.length).toBe(1); - expect(domains[0]).toBe("evil.com"); - }); - - it("should collect redacted HTTP domains", () => { - sanitizeContent("Visit http://example.com"); - const domains = getRedactedDomains(); - expect(domains.length).toBe(1); - expect(domains[0]).toBe("example.com"); - }); - - it("should collect redacted dangerous protocols", () => { - sanitizeContent("Click javascript:alert(1)"); - const domains = getRedactedDomains(); - expect(domains.length).toBe(1); - expect(domains[0]).toBe("javascript:"); - }); - - it("should collect multiple redacted domains", () => { - sanitizeContent("Visit https://bad1.com and http://bad2.com"); - const domains = getRedactedDomains(); - expect(domains.length).toBe(2); - expect(domains).toContain("bad1.com"); - expect(domains).toContain("bad2.com"); - }); - - it("should not collect allowed domains", () => { - sanitizeContent("Visit https://github.com/repo"); - const domains = getRedactedDomains(); - expect(domains.length).toBe(0); - }); - - it("should clear collected domains", () => { - sanitizeContent("Visit https://evil.com"); - expect(getRedactedDomains().length).toBe(1); - clearRedactedDomains(); - expect(getRedactedDomains().length).toBe(0); - }); - - it("should return a copy of domains array", () => { - sanitizeContent("Visit https://evil.com"); - const domains1 = getRedactedDomains(); - const domains2 = getRedactedDomains(); - expect(domains1).not.toBe(domains2); - expect(domains1).toEqual(domains2); - }); - - describe("writeRedactedDomainsLog", () => { - const testDir = "/tmp/gh-aw-test-redacted"; - const testFile = `${testDir}/redacted-urls.log`; - - afterEach(() => { - // Clean up test files - if (fs.existsSync(testFile)) { - fs.unlinkSync(testFile); - } - if (fs.existsSync(testDir)) { - fs.rmdirSync(testDir, { recursive: true }); - } - }); - - it("should return null when no domains collected", () => { - const result = writeRedactedDomainsLog(testFile); - expect(result).toBeNull(); - expect(fs.existsSync(testFile)).toBe(false); - }); - - it("should write domains to log file", () => { - sanitizeContent("Visit https://evil.com/malware"); - const result = writeRedactedDomainsLog(testFile); - expect(result).toBe(testFile); - expect(fs.existsSync(testFile)).toBe(true); - - const content = fs.readFileSync(testFile, "utf8"); - expect(content).toContain("evil.com"); - // Should NOT contain the full URL, only the domain - expect(content).not.toContain("https://evil.com/malware"); - }); - - it("should write multiple domains to log file", () => { - sanitizeContent("Visit https://bad1.com and http://bad2.com"); - writeRedactedDomainsLog(testFile); - - const content = fs.readFileSync(testFile, "utf8"); - const lines = content.trim().split("\n"); - expect(lines.length).toBe(2); - expect(content).toContain("bad1.com"); - expect(content).toContain("bad2.com"); - }); - - it("should create directory if it does not exist", () => { - const nestedFile = `${testDir}/nested/redacted-urls.log`; - sanitizeContent("Visit https://evil.com"); - writeRedactedDomainsLog(nestedFile); - expect(fs.existsSync(nestedFile)).toBe(true); - - // Clean up nested directory - fs.unlinkSync(nestedFile); - fs.rmdirSync(path.dirname(nestedFile)); - }); - - it("should use default path when not specified", () => { - const defaultPath = "/tmp/gh-aw/redacted-urls.log"; - sanitizeContent("Visit https://evil.com"); - const result = writeRedactedDomainsLog(); - expect(result).toBe(defaultPath); - expect(fs.existsSync(defaultPath)).toBe(true); - - // Clean up - fs.unlinkSync(defaultPath); - }); - }); - }); -}); +import{describe,it,expect,beforeEach,afterEach,vi}from"vitest";describe("sanitize_content.cjs",()=>{let mockCore,sanitizeContent;beforeEach(async()=>{mockCore={debug:vi.fn(),info:vi.fn(),warning:vi.fn(),error:vi.fn()},global.core=mockCore;const module=await import("./sanitize_content.cjs");sanitizeContent=module.sanitizeContent}),afterEach(()=>{delete global.core,delete process.env.GH_AW_ALLOWED_DOMAINS,delete process.env.GH_AW_COMMAND,delete process.env.GITHUB_SERVER_URL,delete process.env.GITHUB_API_URL}),describe("basic sanitization",()=>{it("should return empty string for null or undefined input",()=>{expect(sanitizeContent(null)).toBe(""),expect(sanitizeContent(void 0)).toBe("")}),it("should return empty string for non-string input",()=>{expect(sanitizeContent(123)).toBe(""),expect(sanitizeContent({})).toBe(""),expect(sanitizeContent([])).toBe("")}),it("should trim whitespace",()=>{expect(sanitizeContent(" hello world ")).toBe("hello world"),expect(sanitizeContent("\n\thello\n\t")).toBe("hello")}),it("should preserve normal text",()=>{expect(sanitizeContent("Hello, this is normal text.")).toBe("Hello, this is normal text.")})}),describe("command neutralization",()=>{beforeEach(()=>{process.env.GH_AW_COMMAND="bot"}),it("should neutralize command at start of text",()=>{const result=sanitizeContent("/bot do something");expect(result).toBe("`/bot` do something")}),it("should neutralize command after whitespace",()=>{const result=sanitizeContent(" /bot do something");expect(result).toBe("`/bot` do something")}),it("should not neutralize command in middle of text",()=>{const result=sanitizeContent("hello /bot world");expect(result).toBe("hello /bot world")}),it("should handle special regex characters in command name",()=>{process.env.GH_AW_COMMAND="my-bot+test";const result=sanitizeContent("/my-bot+test action");expect(result).toBe("`/my-bot+test` action")}),it("should not neutralize when no command is set",()=>{delete process.env.GH_AW_COMMAND;const result=sanitizeContent("/bot do something");expect(result).toBe("/bot do something")})}),describe("@mention neutralization",()=>{it("should neutralize @mentions",()=>{const result=sanitizeContent("Hello @user");expect(result).toBe("Hello `@user`")}),it("should neutralize @org/team mentions",()=>{const result=sanitizeContent("Hello @myorg/myteam");expect(result).toBe("Hello `@myorg/myteam`")}),it("should not neutralize @mentions already in backticks",()=>{const result=sanitizeContent("Already `@user` mentioned");expect(result).toBe("Already `@user` mentioned")}),it("should neutralize multiple @mentions",()=>{const result=sanitizeContent("@user1 and @user2 are here");expect(result).toBe("`@user1` and `@user2` are here")}),it("should not neutralize email addresses",()=>{const result=sanitizeContent("Contact email@example.com");expect(result).toBe("Contact email@example.com")})}),describe("@mention allowedAliases",()=>{it("should not neutralize mentions in allowedAliases list",()=>{const result=sanitizeContent("Hello @author",{allowedAliases:["author"]});expect(result).toBe("Hello @author")}),it("should neutralize mentions not in allowedAliases list",()=>{const result=sanitizeContent("Hello @other",{allowedAliases:["author"]});expect(result).toBe("Hello `@other`")}),it("should handle multiple mentions with some allowed",()=>{const result=sanitizeContent("Hello @author and @other",{allowedAliases:["author"]});expect(result).toBe("Hello @author and `@other`")}),it("should handle case-insensitive matching for allowedAliases",()=>{const result=sanitizeContent("Hello @Author",{allowedAliases:["author"]});expect(result).toBe("Hello @Author")}),it("should handle multiple allowed aliases",()=>{const result=sanitizeContent("Hello @user1 and @user2 and @other",{allowedAliases:["user1","user2"]});expect(result).toBe("Hello @user1 and @user2 and `@other`")}),it("should work with options object containing both maxLength and allowedAliases",()=>{const result=sanitizeContent("Hello @author and @other",{maxLength:524288,allowedAliases:["author"]});expect(result).toBe("Hello @author and `@other`")}),it("should handle empty allowedAliases array",()=>{const result=sanitizeContent("Hello @user",{allowedAliases:[]});expect(result).toBe("Hello `@user`")}),it("should not neutralize org/team mentions in allowedAliases",()=>{const result=sanitizeContent("Hello @myorg/myteam",{allowedAliases:["myorg/myteam"]});expect(result).toBe("Hello @myorg/myteam")}),it("should preserve backward compatibility with numeric maxLength parameter",()=>{const result=sanitizeContent("Hello @user",524288);expect(result).toBe("Hello `@user`")}),it("should log escaped mentions for debugging",()=>{const result=sanitizeContent("Hello @user1 and @user2",{allowedAliases:["user1"]});expect(result).toBe("Hello @user1 and `@user2`"),expect(mockCore.info).toHaveBeenCalledWith("Escaped mention: @user2 (not in allowed list)")}),it("should log multiple escaped mentions",()=>{const result=sanitizeContent("@user1 @user2 @user3",{allowedAliases:["user1"]});expect(result).toBe("@user1 `@user2` `@user3`"),expect(mockCore.info).toHaveBeenCalledWith("Escaped mention: @user2 (not in allowed list)"),expect(mockCore.info).toHaveBeenCalledWith("Escaped mention: @user3 (not in allowed list)")}),it("should not log when all mentions are allowed",()=>{const result=sanitizeContent("Hello @user1 and @user2",{allowedAliases:["user1","user2"]});expect(result).toBe("Hello @user1 and @user2");const escapedMentionCalls=mockCore.info.mock.calls.filter(call=>call[0].includes("Escaped mention"));expect(escapedMentionCalls).toHaveLength(0)})}),describe("XML comments removal",()=>{it("should remove XML comments",()=>{const result=sanitizeContent("Hello \x3c!-- comment --\x3e world");expect(result).toBe("Hello world")}),it("should remove malformed XML comments",()=>{const result=sanitizeContent("Hello \x3c!--! comment --!> world");expect(result).toBe("Hello world")}),it("should remove multiline XML comments",()=>{const result=sanitizeContent("Hello \x3c!-- multi\nline\ncomment --\x3e world");expect(result).toBe("Hello world")})}),describe("XML/HTML tag conversion",()=>{it("should convert opening tags to parentheses",()=>{const result=sanitizeContent("Hello
world
");expect(result).toBe("Hello (div)world(/div)")}),it("should convert tags with attributes to parentheses",()=>{const result=sanitizeContent('
content
');expect(result).toBe('(div class="test")content(/div)')}),it("should preserve allowed safe tags",()=>{["b","blockquote","br","code","details","em","h1","h2","h3","h4","h5","h6","hr","i","li","ol","p","pre","strong","sub","summary","sup","table","tbody","td","th","thead","tr","ul"].forEach(tag=>{const result=sanitizeContent(`<${tag}>content`);expect(result).toBe(`<${tag}>content`)})}),it("should preserve self-closing br tags",()=>{const result=sanitizeContent("Hello
world");expect(result).toBe("Hello
world")}),it("should preserve br tags without slash",()=>{const result=sanitizeContent("Hello
world");expect(result).toBe("Hello
world")}),it("should convert self-closing tags that are not allowed",()=>{const result=sanitizeContent("Hello world");expect(result).toBe("Hello (img/) world")}),it("should handle CDATA sections",()=>{const result=sanitizeContent("alert('xss')<\/script>]]>");expect(result).toBe("(![CDATA[(script)alert('xss')(/script)]])")}),it("should preserve inline formatting tags",()=>{const input="This is bold, italic, and bold too text.",result=sanitizeContent(input);expect(result).toBe(input)}),it("should preserve list structure tags",()=>{const input="",result=sanitizeContent(input);expect(result).toBe(input)}),it("should preserve ordered list tags",()=>{const input="
  1. First
  2. Second
",result=sanitizeContent(input);expect(result).toBe(input)}),it("should preserve blockquote tags",()=>{const input="
This is a quote
",result=sanitizeContent(input);expect(result).toBe(input)}),it("should handle mixed allowed tags with formatting",()=>{const input="

This is bold and italic text.
New line here.

",result=sanitizeContent(input);expect(result).toBe(input)}),it("should handle nested list structure",()=>{const input="",result=sanitizeContent(input);expect(result).toBe(input)}),it("should preserve details and summary tags",()=>{const result1=sanitizeContent("
content
");expect(result1).toBe("
content
");const result2=sanitizeContent("content");expect(result2).toBe("content")}),it("should convert removed tags that are no longer allowed",()=>{const result3=sanitizeContent("content");expect(result3).toBe("(u)content(/u)")}),it("should preserve heading tags h1-h6",()=>{["h1","h2","h3","h4","h5","h6"].forEach(tag=>{const input=`<${tag}>Heading`,result=sanitizeContent(input);expect(result).toBe(input)})}),it("should preserve hr tag",()=>{const result=sanitizeContent("Content before
Content after");expect(result).toBe("Content before
Content after")}),it("should preserve pre tag",()=>{const input="
Code block content
",result=sanitizeContent(input);expect(result).toBe(input)}),it("should preserve sub and sup tags",()=>{const result1=sanitizeContent("H2O");expect(result1).toBe("H2O");const result2=sanitizeContent("E=mc2");expect(result2).toBe("E=mc2")}),it("should preserve table structure tags",()=>{const input="
Header
Data
",result=sanitizeContent(input);expect(result).toBe(input)})}),describe("ANSI escape sequence removal",()=>{it("should remove ANSI color codes",()=>{const result=sanitizeContent("red text");expect(result).toBe("red text")}),it("should remove various ANSI codes",()=>{const result=sanitizeContent("Bold Green");expect(result).toBe("Bold Green")})}),describe("control character removal",()=>{it("should remove control characters",()=>{const result=sanitizeContent("test\0content");expect(result).toBe("testcontent")}),it("should preserve newlines and tabs",()=>{const result=sanitizeContent("test\ncontent\twith\ttabs");expect(result).toBe("test\ncontent\twith\ttabs")}),it("should remove DEL character",()=>{const result=sanitizeContent("testcontent");expect(result).toBe("testcontent")})}),describe("URL protocol sanitization",()=>{it("should allow HTTPS URLs",()=>{const result=sanitizeContent("Visit https://github.com");expect(result).toBe("Visit https://github.com")}),it("should redact HTTP URLs",()=>{const result=sanitizeContent("Visit http://example.com");expect(result).toContain("(redacted)"),expect(mockCore.info).toHaveBeenCalled()}),it("should redact javascript: URLs",()=>{const result=sanitizeContent("Click javascript:alert('xss')");expect(result).toContain("(redacted)")}),it("should redact data: URLs",()=>{const result=sanitizeContent("Image data:image/png;base64,abc123");expect(result).toContain("(redacted)")}),it("should preserve file paths with colons",()=>{const result=sanitizeContent("C:\\path\\to\\file");expect(result).toBe("C:\\path\\to\\file")}),it("should preserve namespace patterns",()=>{const result=sanitizeContent("std::vector::push_back");expect(result).toBe("std::vector::push_back")})}),describe("URL domain filtering",()=>{it("should allow default GitHub domains",()=>{["https://github.com/repo","https://api.github.com/endpoint","https://raw.githubusercontent.com/file","https://example.github.io/page"].forEach(url=>{const result=sanitizeContent(`Visit ${url}`);expect(result).toBe(`Visit ${url}`)})}),it("should redact disallowed domains",()=>{const result=sanitizeContent("Visit https://evil.com/malicious");expect(result).toContain("(redacted)"),expect(mockCore.info).toHaveBeenCalled()}),it("should use custom allowed domains from environment",()=>{process.env.GH_AW_ALLOWED_DOMAINS="example.com,trusted.net";const result=sanitizeContent("Visit https://example.com/page");expect(result).toBe("Visit https://example.com/page")}),it("should extract and allow GitHub Enterprise domains",()=>{process.env.GITHUB_SERVER_URL="https://github.company.com";const result=sanitizeContent("Visit https://github.company.com/repo");expect(result).toBe("Visit https://github.company.com/repo")}),it("should allow subdomains of allowed domains",()=>{const result=sanitizeContent("Visit https://subdomain.github.com/page");expect(result).toBe("Visit https://subdomain.github.com/page")}),it("should log redacted domains",()=>{sanitizeContent("Visit https://verylongdomainnamefortest.com/page"),expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Redacted URL:")),expect(mockCore.debug).toHaveBeenCalledWith(expect.stringContaining("Redacted URL (full):"))})}),describe("bot trigger neutralization",()=>{it("should neutralize 'fixes #123' patterns",()=>{const result=sanitizeContent("This fixes #123");expect(result).toBe("This `fixes #123`")}),it("should neutralize 'closes #456' patterns",()=>{const result=sanitizeContent("PR closes #456");expect(result).toBe("PR `closes #456`")}),it("should neutralize 'resolves #789' patterns",()=>{const result=sanitizeContent("This resolves #789");expect(result).toBe("This `resolves #789`")}),it("should handle various bot trigger verbs",()=>{["fix","fixes","close","closes","resolve","resolves"].forEach(verb=>{const result=sanitizeContent(`This ${verb} #123`);expect(result).toBe(`This \`${verb} #123\``)})}),it("should neutralize alphanumeric issue references",()=>{const result=sanitizeContent("fixes #abc123def");expect(result).toBe("`fixes #abc123def`")})}),describe("content truncation",()=>{it("should truncate content exceeding max length",()=>{const longContent="x".repeat(6e5),result=sanitizeContent(longContent);expect(result.length).toBeLessThan(longContent.length),expect(result).toContain("[Content truncated due to length]")}),it("should truncate content exceeding max lines",()=>{const manyLines=Array(7e4).fill("line").join("\n"),result=sanitizeContent(manyLines);expect(result.split("\n").length).toBeLessThan(7e4),expect(result).toContain("[Content truncated due to line count]")}),it("should respect custom max length parameter",()=>{const content="x".repeat(200),result=sanitizeContent(content,100);expect(result.length).toBeLessThanOrEqual(150),expect(result).toContain("[Content truncated")}),it("should not truncate short content",()=>{const result=sanitizeContent("This is a short message");expect(result).toBe("This is a short message"),expect(result).not.toContain("[Content truncated")})}),describe("combined sanitization",()=>{it("should apply all sanitizations correctly",()=>{const result=sanitizeContent(" \n \x3c!-- comment --\x3e\n Hello @user, visit https://github.com\n \n'; - const repeatedContent = lineContent.repeat(70000); // Will exceed line limit - - const testFile = "/tmp/gh-aw/test-large-mixed.txt"; - fs.writeFileSync(testFile, repeatedContent); - process.env.GH_AW_SAFE_OUTPUTS = testFile; - - // Execute the script - await eval(`(async () => { ${sanitizeScript} })()`); - - const outputCall = mockCore.setOutput.mock.calls.find(call => call[0] === "output"); - expect(outputCall).toBeDefined(); - const result = outputCall[1]; - - // Should be truncated (could be due to line count or length limit) - expect(result).toMatch(/\[Content truncated due to (line count|length)\]/); - - // But should still sanitize what it processes - expect(result).toContain("`@user`"); - expect(result).toContain("(redacted)"); // evil.com - expect(result).toContain("(script)"); // XML tag conversion - - fs.unlinkSync(testFile); - }); - - it("should preserve log message format for short content", async () => { - const shortContent = "Short message with @user"; - const testFile = "/tmp/gh-aw/test-short.txt"; - fs.writeFileSync(testFile, shortContent); - process.env.GH_AW_SAFE_OUTPUTS = testFile; - - // Execute the script - await eval(`(async () => { ${sanitizeScript} })()`); - - const logCalls = mockCore.info.mock.calls; - const outputLogCall = logCalls.find(call => call[0] && call[0].includes("Collected agentic output (sanitized):")); - - expect(outputLogCall).toBeDefined(); - // Should not have ... for short content - expect(outputLogCall[0]).not.toContain("..."); - expect(outputLogCall[0]).toContain("`@user`"); - - fs.unlinkSync(testFile); - }); - }); - - describe("Command Neutralization", () => { - beforeEach(() => { - // Clear all mocks before each test - vi.clearAllMocks(); - // Ensure test directory exists - if (!fs.existsSync("/tmp/gh-aw")) { - fs.mkdirSync("/tmp/gh-aw", { recursive: true }); - } - }); - - it("should neutralize command at the start of text", async () => { - process.env.GH_AW_COMMAND = "test-bot"; - const content = "/test-bot please analyze this code"; - const testFile = "/tmp/gh-aw/test-command-start.txt"; - fs.writeFileSync(testFile, content); - process.env.GH_AW_SAFE_OUTPUTS = testFile; - - // Execute the script - await eval(`(async () => { ${sanitizeScript} })()`); - - const outputCall = mockCore.setOutput.mock.calls.find(call => call[0] === "output"); - expect(outputCall).toBeDefined(); - const result = outputCall[1]; - - // Command should be neutralized with backticks - expect(result).toContain("`/test-bot`"); - expect(result).not.toMatch(/^\/test-bot/); // Should not start with plain command - - fs.unlinkSync(testFile); - delete process.env.GH_AW_COMMAND; - }); - - it("should not neutralize command when it appears later in text", async () => { - process.env.GH_AW_COMMAND = "helper"; - const content = "I need help from /helper please"; - const testFile = "/tmp/gh-aw/test-command-middle.txt"; - fs.writeFileSync(testFile, content); - process.env.GH_AW_SAFE_OUTPUTS = testFile; - - // Execute the script - await eval(`(async () => { ${sanitizeScript} })()`); - - const outputCall = mockCore.setOutput.mock.calls.find(call => call[0] === "output"); - expect(outputCall).toBeDefined(); - const result = outputCall[1]; - - // Command in the middle should not be neutralized - expect(result).toContain("/helper"); - // The command should remain as is since it's not at the start - expect(result).toContain("I need help from /helper please"); - - fs.unlinkSync(testFile); - delete process.env.GH_AW_COMMAND; - }); - - it("should handle command at start with leading whitespace", async () => { - process.env.GH_AW_COMMAND = "review-bot"; - const content = " \n/review-bot analyze this PR"; - const testFile = "/tmp/gh-aw/test-command-whitespace.txt"; - fs.writeFileSync(testFile, content); - process.env.GH_AW_SAFE_OUTPUTS = testFile; - - // Execute the script - await eval(`(async () => { ${sanitizeScript} })()`); - - const outputCall = mockCore.setOutput.mock.calls.find(call => call[0] === "output"); - expect(outputCall).toBeDefined(); - const result = outputCall[1]; - - // Command should be neutralized even with leading whitespace - expect(result).toContain("`/review-bot`"); - - fs.unlinkSync(testFile); - delete process.env.GH_AW_COMMAND; - }); - - it("should not modify text when no command is configured", async () => { - // No GH_AW_COMMAND set - const content = "/some-bot do something"; - const testFile = "/tmp/gh-aw/test-no-command.txt"; - fs.writeFileSync(testFile, content); - process.env.GH_AW_SAFE_OUTPUTS = testFile; - - // Execute the script - await eval(`(async () => { ${sanitizeScript} })()`); - - const outputCall = mockCore.setOutput.mock.calls.find(call => call[0] === "output"); - expect(outputCall).toBeDefined(); - const result = outputCall[1]; - - // Text should remain as is (no command neutralization) - expect(result).toContain("/some-bot"); - - fs.unlinkSync(testFile); - }); - - it("should handle special characters in command name", async () => { - process.env.GH_AW_COMMAND = "test-bot_v2"; - const content = "/test-bot_v2 execute task"; - const testFile = "/tmp/gh-aw/test-special-chars.txt"; - fs.writeFileSync(testFile, content); - process.env.GH_AW_SAFE_OUTPUTS = testFile; - - // Execute the script - await eval(`(async () => { ${sanitizeScript} })()`); - - const outputCall = mockCore.setOutput.mock.calls.find(call => call[0] === "output"); - expect(outputCall).toBeDefined(); - const result = outputCall[1]; - - // Command with special chars should be neutralized - expect(result).toContain("`/test-bot_v2`"); - - fs.unlinkSync(testFile); - delete process.env.GH_AW_COMMAND; - }); - - it("should combine command neutralization with other sanitizations", async () => { - process.env.GH_AW_COMMAND = "analyze-bot"; - const content = "/analyze-bot check @user for https://evil.com issues"; - const testFile = "/tmp/gh-aw/test-combined.txt"; - fs.writeFileSync(testFile, content); - process.env.GH_AW_SAFE_OUTPUTS = testFile; - - // Execute the script - await eval(`(async () => { ${sanitizeScript} })()`); - - const outputCall = mockCore.setOutput.mock.calls.find(call => call[0] === "output"); - expect(outputCall).toBeDefined(); - const result = outputCall[1]; - - // Command should be neutralized - expect(result).toContain("`/analyze-bot`"); - // @mention should be neutralized - expect(result).toContain("`@user`"); - // Non-whitelisted domain should be redacted - expect(result).toContain("(redacted)"); - - fs.unlinkSync(testFile); - delete process.env.GH_AW_COMMAND; - }); - }); - - describe("URL Redaction Logging", () => { - beforeEach(() => { - // Clear all mocks before each test - vi.clearAllMocks(); - // Ensure test directory exists - if (!fs.existsSync("/tmp/gh-aw")) { - fs.mkdirSync("/tmp/gh-aw", { recursive: true }); - } - }); - - it("should log when HTTPS URLs with disallowed domains are redacted", async () => { - const content = "Check out https://evil.com/malware for details"; - const testFile = "/tmp/gh-aw/test-url-logging-https.txt"; - fs.writeFileSync(testFile, content); - process.env.GH_AW_SAFE_OUTPUTS = testFile; - - // Execute the script - await eval(`(async () => { ${sanitizeScript} })()`); - - // Check that core.info was called with the truncated domain - const infoCalls = mockCore.info.mock.calls; - const redactionLog = infoCalls.find(call => call[0] && call[0].includes("Redacted URL: evil.com")); - - expect(redactionLog).toBeDefined(); - expect(redactionLog[0]).toBe("Redacted URL: evil.com"); - - // Check that core.debug was called with the full URL - const debugCalls = mockCore.debug.mock.calls; - const fullUrlLog = debugCalls.find(call => call[0] && call[0].includes("Redacted URL (full): https://evil.com/malware")); - expect(fullUrlLog).toBeDefined(); - - fs.unlinkSync(testFile); - }); - - it("should log when HTTP URLs are redacted", async () => { - const content = "Visit http://example.com for more info"; - const testFile = "/tmp/gh-aw/test-url-logging-http.txt"; - fs.writeFileSync(testFile, content); - process.env.GH_AW_SAFE_OUTPUTS = testFile; - - // Execute the script - await eval(`(async () => { ${sanitizeScript} })()`); - - // Check that core.info was called with the truncated domain - const infoCalls = mockCore.info.mock.calls; - const redactionLog = infoCalls.find(call => call[0] && call[0].includes("Redacted URL: example.com")); - - expect(redactionLog).toBeDefined(); - expect(redactionLog[0]).toBe("Redacted URL: example.com"); - - // Check that core.debug was called with the full URL - const debugCalls = mockCore.debug.mock.calls; - const fullUrlLog = debugCalls.find(call => call[0] && call[0].includes("Redacted URL (full): http://example.com")); - expect(fullUrlLog).toBeDefined(); - - fs.unlinkSync(testFile); - }); - - it("should log when javascript: URLs are redacted", async () => { - const content = "Click here: javascript:alert('xss')"; - const testFile = "/tmp/gh-aw/test-url-logging-js.txt"; - fs.writeFileSync(testFile, content); - process.env.GH_AW_SAFE_OUTPUTS = testFile; - - // Execute the script - await eval(`(async () => { ${sanitizeScript} })()`); - - // Check that core.info was called with truncated version - // Note: The regex stops at '(' so it only captures "javascript:alert(" - const infoCalls = mockCore.info.mock.calls; - const redactionLog = infoCalls.find(call => call[0] && call[0].includes("Redacted URL: javascript:a")); - - expect(redactionLog).toBeDefined(); - expect(redactionLog[0]).toBe("Redacted URL: javascript:a..."); - - // Check that core.debug was called with the full captured URL - const debugCalls = mockCore.debug.mock.calls; - const fullUrlLog = debugCalls.find(call => call[0] && call[0].includes("Redacted URL (full): javascript:alert(")); - expect(fullUrlLog).toBeDefined(); - - fs.unlinkSync(testFile); - }); - - it("should log multiple URL redactions", async () => { - const content = "Links: http://bad1.com, https://bad2.com, ftp://bad3.com"; - const testFile = "/tmp/gh-aw/test-url-logging-multiple.txt"; - fs.writeFileSync(testFile, content); - process.env.GH_AW_SAFE_OUTPUTS = testFile; - - // Execute the script - await eval(`(async () => { ${sanitizeScript} })()`); - - // Check that core.info was called for each redacted URL (with truncated domains) - const infoCalls = mockCore.info.mock.calls; - const redactionLogs = infoCalls.filter(call => call[0] && call[0].startsWith("Redacted URL:")); - - expect(redactionLogs.length).toBeGreaterThanOrEqual(3); - expect(redactionLogs.some(log => log[0].includes("bad1.com"))).toBe(true); - expect(redactionLogs.some(log => log[0].includes("bad2.com"))).toBe(true); - expect(redactionLogs.some(log => log[0].includes("bad3.com"))).toBe(true); - - fs.unlinkSync(testFile); - }); - - it("should not log when HTTPS URLs with allowed domains are preserved", async () => { - const content = "Visit https://github.com for more info"; - const testFile = "/tmp/gh-aw/test-url-logging-allowed.txt"; - fs.writeFileSync(testFile, content); - process.env.GH_AW_SAFE_OUTPUTS = testFile; - - // Execute the script - await eval(`(async () => { ${sanitizeScript} })()`); - - // Check that core.info was NOT called for allowed URLs - const infoCalls = mockCore.info.mock.calls; - const redactionLogs = infoCalls.filter(call => call[0] && call[0].includes("Redacted URL: github.com")); - - expect(redactionLogs.length).toBe(0); - - fs.unlinkSync(testFile); - }); - - it("should log when data: URLs are redacted", async () => { - const content = "Image: data:text/html,"; - const testFile = "/tmp/gh-aw/test-url-logging-data.txt"; - fs.writeFileSync(testFile, content); - process.env.GH_AW_SAFE_OUTPUTS = testFile; - - // Execute the script - await eval(`(async () => { ${sanitizeScript} })()`); - - // Check that core.info was called with truncated version - // Note: The regex stops at '<' so it only captures "data:text/html," - const infoCalls = mockCore.info.mock.calls; - const redactionLog = infoCalls.find(call => call[0] && call[0].includes("Redacted URL: data:text/ht")); - - expect(redactionLog).toBeDefined(); - - fs.unlinkSync(testFile); - }); - - it("should handle mixed content with both redacted and allowed URLs", async () => { - const content = "Good: https://github.com/repo Bad: https://evil.com/bad More: http://another.bad"; - const testFile = "/tmp/gh-aw/test-url-logging-mixed.txt"; - fs.writeFileSync(testFile, content); - process.env.GH_AW_SAFE_OUTPUTS = testFile; - - // Execute the script - await eval(`(async () => { ${sanitizeScript} })()`); - - // Check that core.info was called only for disallowed URLs (with truncated domains) - const infoCalls = mockCore.info.mock.calls; - const redactionLogs = infoCalls.filter(call => call[0] && call[0].startsWith("Redacted URL:")); - - expect(redactionLogs.length).toBeGreaterThanOrEqual(2); - expect(redactionLogs.some(log => log[0].includes("evil.com"))).toBe(true); - expect(redactionLogs.some(log => log[0].includes("another.bad"))).toBe(true); - expect(redactionLogs.some(log => log[0].includes("github.com"))).toBe(false); - - fs.unlinkSync(testFile); - }); - }); -}); +import{describe,it,expect,beforeEach,afterEach,vi}from"vitest";import fs from"fs";import path from"path";const mockCore={debug:vi.fn(),info:vi.fn(),notice:vi.fn(),warning:vi.fn(),error:vi.fn(),setFailed:vi.fn(),setOutput:vi.fn(),exportVariable:vi.fn(),setSecret:vi.fn(),getInput:vi.fn(),getBooleanInput:vi.fn(),getMultilineInput:vi.fn(),getState:vi.fn(),saveState:vi.fn(),startGroup:vi.fn(),endGroup:vi.fn(),group:vi.fn(),addPath:vi.fn(),setCommandEcho:vi.fn(),isDebug:vi.fn().mockReturnValue(!1),getIDToken:vi.fn(),toPlatformPath:vi.fn(),toPosixPath:vi.fn(),toWin32Path:vi.fn(),summary:{addRaw:vi.fn().mockReturnThis(),write:vi.fn().mockResolvedValue()}};global.core=mockCore,describe("sanitize_output.cjs",()=>{let sanitizeScript,sanitizeContentFunction;beforeEach(()=>{vi.clearAllMocks(),delete process.env.GH_AW_SAFE_OUTPUTS,delete process.env.GH_AW_ALLOWED_DOMAINS;const scriptPath=path.join(process.cwd(),"sanitize_output.cjs");sanitizeScript=fs.readFileSync(scriptPath,"utf8");const scriptWithExport=sanitizeScript.replace("await main();","global.testSanitizeContent = sanitizeContent;");eval(scriptWithExport),sanitizeContentFunction=global.testSanitizeContent}),describe("sanitizeContent function",()=>{it("should handle null and undefined inputs",()=>{expect(sanitizeContentFunction(null)).toBe(""),expect(sanitizeContentFunction(void 0)).toBe(""),expect(sanitizeContentFunction("")).toBe("")}),it("should neutralize @mentions by wrapping in backticks",()=>{const result=sanitizeContentFunction("Hello @user and @org/team");expect(result).toContain("`@user`"),expect(result).toContain("`@org/team`")}),it("should not neutralize @mentions inside code blocks",()=>{const result=sanitizeContentFunction("Check `@user` in code and @realuser outside");expect(result).toContain("`@user`"),expect(result).toContain("`@realuser`")}),it("should neutralize bot trigger phrases",()=>{const result=sanitizeContentFunction("This fixes #123 and closes #456. Also resolves #789");expect(result).toContain("`fixes #123`"),expect(result).toContain("`closes #456`"),expect(result).toContain("`resolves #789`")}),it("should remove control characters except newlines and tabs",()=>{const result=sanitizeContentFunction("Hello\0world\f\nNext line\tbad");expect(result).not.toContain("\0"),expect(result).not.toContain("\f"),expect(result).not.toContain(""),expect(result).toContain("\n"),expect(result).toContain("\t")}),it("should convert XML tags to parentheses format",()=>{const result=sanitizeContentFunction(']]>"); + expect(result).toBe("(![CDATA[(script)alert('xss')(/script)]])"); + }); + + it("should preserve inline formatting tags", () => { + const input = "This is bold, italic, and bold too text."; + const result = sanitizeContent(input); + expect(result).toBe(input); + }); + + it("should preserve list structure tags", () => { + const input = ""; + const result = sanitizeContent(input); + expect(result).toBe(input); + }); + + it("should preserve ordered list tags", () => { + const input = "
  1. First
  2. Second
"; + const result = sanitizeContent(input); + expect(result).toBe(input); + }); + + it("should preserve blockquote tags", () => { + const input = "
This is a quote
"; + const result = sanitizeContent(input); + expect(result).toBe(input); + }); + + it("should handle mixed allowed tags with formatting", () => { + const input = "

This is bold and italic text.
New line here.

"; + const result = sanitizeContent(input); + expect(result).toBe(input); + }); + + it("should handle nested list structure", () => { + const input = ""; + const result = sanitizeContent(input); + expect(result).toBe(input); + }); + + it("should preserve details and summary tags", () => { + const result1 = sanitizeContent("
content
"); + expect(result1).toBe("
content
"); + + const result2 = sanitizeContent("content"); + expect(result2).toBe("content"); + }); + + it("should convert removed tags that are no longer allowed", () => { + // Tag that was previously allowed but is now removed: u + const result3 = sanitizeContent("content"); + expect(result3).toBe("(u)content(/u)"); + }); + + it("should preserve heading tags h1-h6", () => { + const headings = ["h1", "h2", "h3", "h4", "h5", "h6"]; + headings.forEach(tag => { + const input = `<${tag}>Heading`; + const result = sanitizeContent(input); + expect(result).toBe(input); + }); + }); + + it("should preserve hr tag", () => { + const result = sanitizeContent("Content before
Content after"); + expect(result).toBe("Content before
Content after"); + }); + + it("should preserve pre tag", () => { + const input = "
Code block content
"; + const result = sanitizeContent(input); + expect(result).toBe(input); + }); + + it("should preserve sub and sup tags", () => { + const input1 = "H2O"; + const result1 = sanitizeContent(input1); + expect(result1).toBe(input1); + + const input2 = "E=mc2"; + const result2 = sanitizeContent(input2); + expect(result2).toBe(input2); + }); + + it("should preserve table structure tags", () => { + const input = "
Header
Data
"; + const result = sanitizeContent(input); + expect(result).toBe(input); + }); + }); + + describe("ANSI escape sequence removal", () => { + it("should remove ANSI color codes", () => { + const result = sanitizeContent("\x1b[31mred text\x1b[0m"); + expect(result).toBe("red text"); + }); + + it("should remove various ANSI codes", () => { + const result = sanitizeContent("\x1b[1;32mBold Green\x1b[0m"); + expect(result).toBe("Bold Green"); + }); + }); + + describe("control character removal", () => { + it("should remove control characters", () => { + const result = sanitizeContent("test\x00\x01\x02\x03content"); + expect(result).toBe("testcontent"); + }); + + it("should preserve newlines and tabs", () => { + const result = sanitizeContent("test\ncontent\twith\ttabs"); + expect(result).toBe("test\ncontent\twith\ttabs"); + }); + + it("should remove DEL character", () => { + const result = sanitizeContent("test\x7Fcontent"); + expect(result).toBe("testcontent"); + }); + }); + + describe("URL protocol sanitization", () => { + it("should allow HTTPS URLs", () => { + const result = sanitizeContent("Visit https://github.com"); + expect(result).toBe("Visit https://github.com"); + }); + + it("should redact HTTP URLs", () => { + const result = sanitizeContent("Visit http://example.com"); + expect(result).toContain("(redacted)"); + expect(mockCore.info).toHaveBeenCalled(); + }); + + it("should redact javascript: URLs", () => { + const result = sanitizeContent("Click javascript:alert('xss')"); + expect(result).toContain("(redacted)"); + }); + + it("should redact data: URLs", () => { + const result = sanitizeContent("Image data:image/png;base64,abc123"); + expect(result).toContain("(redacted)"); + }); + + it("should preserve file paths with colons", () => { + const result = sanitizeContent("C:\\path\\to\\file"); + expect(result).toBe("C:\\path\\to\\file"); + }); + + it("should preserve namespace patterns", () => { + const result = sanitizeContent("std::vector::push_back"); + expect(result).toBe("std::vector::push_back"); + }); + }); + + describe("URL domain filtering", () => { + it("should allow default GitHub domains", () => { + const urls = ["https://github.com/repo", "https://api.github.com/endpoint", "https://raw.githubusercontent.com/file", "https://example.github.io/page"]; + + urls.forEach(url => { + const result = sanitizeContent(`Visit ${url}`); + expect(result).toBe(`Visit ${url}`); + }); + }); + + it("should redact disallowed domains", () => { + const result = sanitizeContent("Visit https://evil.com/malicious"); + expect(result).toContain("(redacted)"); + expect(mockCore.info).toHaveBeenCalled(); + }); + + it("should use custom allowed domains from environment", () => { + process.env.GH_AW_ALLOWED_DOMAINS = "example.com,trusted.net"; + const result = sanitizeContent("Visit https://example.com/page"); + expect(result).toBe("Visit https://example.com/page"); + }); + + it("should extract and allow GitHub Enterprise domains", () => { + process.env.GITHUB_SERVER_URL = "https://github.company.com"; + const result = sanitizeContent("Visit https://github.company.com/repo"); + expect(result).toBe("Visit https://github.company.com/repo"); + }); + + it("should allow subdomains of allowed domains", () => { + const result = sanitizeContent("Visit https://subdomain.github.com/page"); + expect(result).toBe("Visit https://subdomain.github.com/page"); + }); + + it("should log redacted domains", () => { + sanitizeContent("Visit https://verylongdomainnamefortest.com/page"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Redacted URL:")); + expect(mockCore.debug).toHaveBeenCalledWith(expect.stringContaining("Redacted URL (full):")); + }); + }); + + describe("bot trigger neutralization", () => { + it("should neutralize 'fixes #123' patterns", () => { + const result = sanitizeContent("This fixes #123"); + expect(result).toBe("This `fixes #123`"); + }); + + it("should neutralize 'closes #456' patterns", () => { + const result = sanitizeContent("PR closes #456"); + expect(result).toBe("PR `closes #456`"); + }); + + it("should neutralize 'resolves #789' patterns", () => { + const result = sanitizeContent("This resolves #789"); + expect(result).toBe("This `resolves #789`"); + }); + + it("should handle various bot trigger verbs", () => { + const triggers = ["fix", "fixes", "close", "closes", "resolve", "resolves"]; + triggers.forEach(verb => { + const result = sanitizeContent(`This ${verb} #123`); + expect(result).toBe(`This \`${verb} #123\``); + }); + }); + + it("should neutralize alphanumeric issue references", () => { + const result = sanitizeContent("fixes #abc123def"); + expect(result).toBe("`fixes #abc123def`"); + }); + }); + + describe("content truncation", () => { + it("should truncate content exceeding max length", () => { + const longContent = "x".repeat(600000); + const result = sanitizeContent(longContent); + + expect(result.length).toBeLessThan(longContent.length); + expect(result).toContain("[Content truncated due to length]"); + }); + + it("should truncate content exceeding max lines", () => { + const manyLines = Array(70000).fill("line").join("\n"); + const result = sanitizeContent(manyLines); + + expect(result.split("\n").length).toBeLessThan(70000); + expect(result).toContain("[Content truncated due to line count]"); + }); + + it("should respect custom max length parameter", () => { + const content = "x".repeat(200); + const result = sanitizeContent(content, 100); + + expect(result.length).toBeLessThanOrEqual(100 + 50); // +50 for truncation message + expect(result).toContain("[Content truncated"); + }); + + it("should not truncate short content", () => { + const shortContent = "This is a short message"; + const result = sanitizeContent(shortContent); + + expect(result).toBe(shortContent); + expect(result).not.toContain("[Content truncated"); + }); + }); + + describe("combined sanitization", () => { + it("should apply all sanitizations correctly", () => { + const input = ` + + Hello @user, visit https://github.com + + This fixes #123 + \x1b[31mRed text\x1b[0m + `; + + const result = sanitizeContent(input); + + expect(result).not.toContain(""); + expect(result).toContain("`@user`"); + expect(result).toContain("https://github.com"); + expect(result).not.toContain(""]; + + maliciousInputs.forEach(input => { + const result = sanitizeContent(input); + expect(result).not.toContain(" { + const input = "
Header
Data
"; + const result = sanitizeContent(input); + + expect(result).toBe(input); + }); + }); + + describe("edge cases", () => { + it("should handle empty string", () => { + expect(sanitizeContent("")).toBe(""); + }); + + it("should handle whitespace-only input", () => { + expect(sanitizeContent(" \n\t ")).toBe(""); + }); + + it("should handle content with only control characters", () => { + const result = sanitizeContent("\x00\x01\x02\x03"); + expect(result).toBe(""); + }); + + it("should handle content with multiple consecutive spaces", () => { + const result = sanitizeContent("hello world"); + expect(result).toBe("hello world"); + }); + + it("should handle Unicode characters", () => { + const result = sanitizeContent("Hello 世界 🌍"); + expect(result).toBe("Hello 世界 🌍"); + }); + + it("should handle URLs in query parameters", () => { + const input = "https://github.com/redirect?url=https://github.com/target"; + const result = sanitizeContent(input); + + expect(result).toContain("github.com"); + expect(result).not.toContain("(redacted)"); + }); + + it("should handle nested backticks", () => { + const result = sanitizeContent("Already `@user` and @other"); + expect(result).toBe("Already `@user` and `@other`"); + }); + }); + + describe("redacted domains collection", () => { + let getRedactedDomains; + let clearRedactedDomains; + let writeRedactedDomainsLog; + const fs = require("fs"); + const path = require("path"); + + beforeEach(async () => { + const module = await import("./sanitize_content.cjs"); + getRedactedDomains = module.getRedactedDomains; + clearRedactedDomains = module.clearRedactedDomains; + writeRedactedDomainsLog = module.writeRedactedDomainsLog; + // Clear collected domains before each test + clearRedactedDomains(); + }); + + it("should collect redacted HTTPS domains", () => { + sanitizeContent("Visit https://evil.com/malware"); + const domains = getRedactedDomains(); + expect(domains.length).toBe(1); + expect(domains[0]).toBe("evil.com"); + }); + + it("should collect redacted HTTP domains", () => { + sanitizeContent("Visit http://example.com"); + const domains = getRedactedDomains(); + expect(domains.length).toBe(1); + expect(domains[0]).toBe("example.com"); + }); + + it("should collect redacted dangerous protocols", () => { + sanitizeContent("Click javascript:alert(1)"); + const domains = getRedactedDomains(); + expect(domains.length).toBe(1); + expect(domains[0]).toBe("javascript:"); + }); + + it("should collect multiple redacted domains", () => { + sanitizeContent("Visit https://bad1.com and http://bad2.com"); + const domains = getRedactedDomains(); + expect(domains.length).toBe(2); + expect(domains).toContain("bad1.com"); + expect(domains).toContain("bad2.com"); + }); + + it("should not collect allowed domains", () => { + sanitizeContent("Visit https://github.com/repo"); + const domains = getRedactedDomains(); + expect(domains.length).toBe(0); + }); + + it("should clear collected domains", () => { + sanitizeContent("Visit https://evil.com"); + expect(getRedactedDomains().length).toBe(1); + clearRedactedDomains(); + expect(getRedactedDomains().length).toBe(0); + }); + + it("should return a copy of domains array", () => { + sanitizeContent("Visit https://evil.com"); + const domains1 = getRedactedDomains(); + const domains2 = getRedactedDomains(); + expect(domains1).not.toBe(domains2); + expect(domains1).toEqual(domains2); + }); + + describe("writeRedactedDomainsLog", () => { + const testDir = "/tmp/gh-aw-test-redacted"; + const testFile = `${testDir}/redacted-urls.log`; + + afterEach(() => { + // Clean up test files + if (fs.existsSync(testFile)) { + fs.unlinkSync(testFile); + } + if (fs.existsSync(testDir)) { + fs.rmdirSync(testDir, { recursive: true }); + } + }); + + it("should return null when no domains collected", () => { + const result = writeRedactedDomainsLog(testFile); + expect(result).toBeNull(); + expect(fs.existsSync(testFile)).toBe(false); + }); + + it("should write domains to log file", () => { + sanitizeContent("Visit https://evil.com/malware"); + const result = writeRedactedDomainsLog(testFile); + expect(result).toBe(testFile); + expect(fs.existsSync(testFile)).toBe(true); + + const content = fs.readFileSync(testFile, "utf8"); + expect(content).toContain("evil.com"); + // Should NOT contain the full URL, only the domain + expect(content).not.toContain("https://evil.com/malware"); + }); + + it("should write multiple domains to log file", () => { + sanitizeContent("Visit https://bad1.com and http://bad2.com"); + writeRedactedDomainsLog(testFile); + + const content = fs.readFileSync(testFile, "utf8"); + const lines = content.trim().split("\n"); + expect(lines.length).toBe(2); + expect(content).toContain("bad1.com"); + expect(content).toContain("bad2.com"); + }); + + it("should create directory if it does not exist", () => { + const nestedFile = `${testDir}/nested/redacted-urls.log`; + sanitizeContent("Visit https://evil.com"); + writeRedactedDomainsLog(nestedFile); + expect(fs.existsSync(nestedFile)).toBe(true); + + // Clean up nested directory + fs.unlinkSync(nestedFile); + fs.rmdirSync(path.dirname(nestedFile)); + }); + + it("should use default path when not specified", () => { + const defaultPath = "/tmp/gh-aw/redacted-urls.log"; + sanitizeContent("Visit https://evil.com"); + const result = writeRedactedDomainsLog(); + expect(result).toBe(defaultPath); + expect(fs.existsSync(defaultPath)).toBe(true); + + // Clean up + fs.unlinkSync(defaultPath); + }); + }); + }); +}); diff --git a/pkg/workflow/js/sanitize_content_core.cjs b/pkg/workflow/js/sanitize_content_core.cjs index 8ebbbbd4b2b..ddf4c364705 100644 --- a/pkg/workflow/js/sanitize_content_core.cjs +++ b/pkg/workflow/js/sanitize_content_core.cjs @@ -1 +1,396 @@ -const redactedDomains=[];function getRedactedDomains(){return[...redactedDomains]}function addRedactedDomain(domain){redactedDomains.push(domain)}function clearRedactedDomains(){redactedDomains.length=0}function writeRedactedDomainsLog(filePath){if(0===redactedDomains.length)return null;const fs=require("fs"),targetPath=filePath||"/tmp/gh-aw/redacted-urls.log",dir=require("path").dirname(targetPath);return fs.existsSync(dir)||fs.mkdirSync(dir,{recursive:!0}),fs.writeFileSync(targetPath,redactedDomains.join("\n")+"\n"),targetPath}function extractDomainsFromUrl(url){if(!url||"string"!=typeof url)return[];try{const hostname=new URL(url).hostname.toLowerCase(),domains=[hostname];return"github.com"===hostname?(domains.push("api.github.com"),domains.push("raw.githubusercontent.com"),domains.push("*.githubusercontent.com")):hostname.startsWith("api.")||(domains.push("api."+hostname),domains.push("raw."+hostname)),domains}catch(e){return[]}}function sanitizeContentCore(content,maxLength){if(!content||"string"!=typeof content)return"";const allowedDomainsEnv=process.env.GH_AW_ALLOWED_DOMAINS;let allowedDomains=allowedDomainsEnv?allowedDomainsEnv.split(",").map(d=>d.trim()).filter(d=>d):["github.com","github.io","githubusercontent.com","githubassets.com","github.dev","codespaces.new"];const githubServerUrl=process.env.GITHUB_SERVER_URL,githubApiUrl=process.env.GITHUB_API_URL;if(githubServerUrl){const serverDomains=extractDomainsFromUrl(githubServerUrl);allowedDomains=allowedDomains.concat(serverDomains)}if(githubApiUrl){const apiDomains=extractDomainsFromUrl(githubApiUrl);allowedDomains=allowedDomains.concat(apiDomains)}allowedDomains=[...new Set(allowedDomains)];let sanitized=content;sanitized=sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g,""),sanitized=sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g,""),sanitized=function(s){const commandName=process.env.GH_AW_COMMAND;if(!commandName)return s;const escapedCommand=commandName.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");return s.replace(new RegExp(`^(\\s*)/(${escapedCommand})\\b`,"i"),"$1`/$2`")}(sanitized),sanitized=sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g,(m,p1,p2)=>("undefined"!=typeof core&&core.info&&core.info(`Escaped mention: @${p2} (not in allowed list)`),`${p1}\`@${p2}\``)),sanitized=sanitized.replace(//g,"").replace(//g,""),sanitized=function(s){const allowedTags=["b","blockquote","br","code","details","em","h1","h2","h3","h4","h5","h6","hr","i","li","ol","p","pre","strong","sub","summary","sup","table","tbody","td","th","thead","tr","ul"];return s=s.replace(//g,(match,content)=>`(![CDATA[${content.replace(/<(\/?[A-Za-z][A-Za-z0-9]*(?:[^>]*?))>/g,"($1)")}]])`),s.replace(/<(\/?[A-Za-z!][^>]*?)>/g,(match,tagContent)=>{const tagNameMatch=tagContent.match(/^\/?\s*([A-Za-z][A-Za-z0-9]*)/);if(tagNameMatch){const tagName=tagNameMatch[1].toLowerCase();if(allowedTags.includes(tagName))return match}return`(${tagContent})`})}(sanitized),sanitized=function(s){return s.replace(/((?:http|ftp|file|ssh|git):\/\/([\w.-]*)(?:[^\s]*)|(?:data|javascript|vbscript|about|mailto|tel):[^\s]+)/gi,(match,_fullMatch,domain)=>{if(domain){const domainLower=domain.toLowerCase(),truncated=domainLower.length>12?domainLower.substring(0,12)+"...":domainLower;"undefined"!=typeof core&&core.info&&core.info(`Redacted URL: ${truncated}`),"undefined"!=typeof core&&core.debug&&core.debug(`Redacted URL (full): ${match}`),addRedactedDomain(domainLower)}else{const protocolMatch=match.match(/^([^:]+):/);if(protocolMatch){const protocol=protocolMatch[1]+":",truncated=match.length>12?match.substring(0,12)+"...":match;"undefined"!=typeof core&&core.info&&core.info(`Redacted URL: ${truncated}`),"undefined"!=typeof core&&core.debug&&core.debug(`Redacted URL (full): ${match}`),addRedactedDomain(protocol)}}return"(redacted)"})}(sanitized),sanitized=function(s,allowed){return s.replace(/https:\/\/([\w.-]+(?::\d+)?)(\/(?:(?!https:\/\/)[^\s,])*)?/gi,(match,hostnameWithPort,pathPart)=>{const hostname=hostnameWithPort.split(":")[0].toLowerCase();if(pathPart=pathPart||"",allowed.some(allowedDomain=>{const normalizedAllowed=allowedDomain.toLowerCase();if(hostname===normalizedAllowed)return!0;if(normalizedAllowed.startsWith("*.")){const baseDomain=normalizedAllowed.substring(2);return hostname.endsWith("."+baseDomain)||hostname===baseDomain}return hostname.endsWith("."+normalizedAllowed)}))return match;{const truncated=hostname.length>12?hostname.substring(0,12)+"...":hostname;return"undefined"!=typeof core&&core.info&&core.info(`Redacted URL: ${truncated}`),"undefined"!=typeof core&&core.debug&&core.debug(`Redacted URL (full): ${match}`),addRedactedDomain(hostname),"(redacted)"}})}(sanitized,allowedDomains);const lines=sanitized.split("\n");if(maxLength=maxLength||524288,lines.length>65e3){const truncationMsg="\n[Content truncated due to line count]",truncatedLines=lines.slice(0,65e3).join("\n")+truncationMsg;sanitized=truncatedLines.length>maxLength?truncatedLines.substring(0,maxLength-truncationMsg.length)+truncationMsg:truncatedLines}else sanitized.length>maxLength&&(sanitized=sanitized.substring(0,maxLength)+"\n[Content truncated due to length]");return sanitized=function(s){return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi,(match,action,ref)=>`\`${action} #${ref}\``)}(sanitized),sanitized.trim()}module.exports={sanitizeContentCore,getRedactedDomains,addRedactedDomain,clearRedactedDomains,writeRedactedDomainsLog,extractDomainsFromUrl}; \ No newline at end of file +// @ts-check +/** + * Core sanitization utilities without mention filtering + * This module provides the base sanitization functions that don't require + * mention resolution or filtering. It's designed to be imported by both + * sanitize_content.cjs (full version) and sanitize_incoming_text.cjs (minimal version). + */ + +/** + * Module-level set to collect redacted URL domains across sanitization calls. + * @type {string[]} + */ +const redactedDomains = []; + +/** + * Gets the list of redacted URL domains collected during sanitization. + * @returns {string[]} Array of redacted domain strings + */ +function getRedactedDomains() { + return [...redactedDomains]; +} + +/** + * Adds a domain to the redacted domains list + * @param {string} domain - Domain to add + */ +function addRedactedDomain(domain) { + redactedDomains.push(domain); +} + +/** + * Clears the list of redacted URL domains. + * Useful for testing or resetting state between operations. + */ +function clearRedactedDomains() { + redactedDomains.length = 0; +} + +/** + * Writes the collected redacted URL domains to a log file. + * Only creates the file if there are redacted domains. + * @param {string} [filePath] - Path to write the log file. Defaults to /tmp/gh-aw/redacted-urls.log + * @returns {string|null} The file path if written, null if no domains to write + */ +function writeRedactedDomainsLog(filePath) { + if (redactedDomains.length === 0) { + return null; + } + + const fs = require("fs"); + const path = require("path"); + const targetPath = filePath || "/tmp/gh-aw/redacted-urls.log"; + + // Ensure directory exists + const dir = path.dirname(targetPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + // Write domains to file, one per line + fs.writeFileSync(targetPath, redactedDomains.join("\n") + "\n"); + + return targetPath; +} + +/** + * Extract domains from a URL and return an array of domain variations + * @param {string} url - The URL to extract domains from + * @returns {string[]} Array of domain variations + */ +function extractDomainsFromUrl(url) { + if (!url || typeof url !== "string") { + return []; + } + + try { + // Parse the URL + const urlObj = new URL(url); + const hostname = urlObj.hostname.toLowerCase(); + + // Return both the exact hostname and common variations + const domains = [hostname]; + + // For github.com, add api and raw content domain variations + if (hostname === "github.com") { + domains.push("api.github.com"); + domains.push("raw.githubusercontent.com"); + domains.push("*.githubusercontent.com"); + } + // For custom GitHub Enterprise domains, add api. prefix and raw content variations + else if (!hostname.startsWith("api.")) { + domains.push("api." + hostname); + // For GitHub Enterprise, raw content is typically served from raw.hostname + domains.push("raw." + hostname); + } + + return domains; + } catch (e) { + // Invalid URL, return empty array + return []; + } +} + +/** + * Core sanitization function without mention filtering + * @param {string} content - The content to sanitize + * @param {number} [maxLength] - Maximum length of content (default: 524288) + * @returns {string} The sanitized content + */ +function sanitizeContentCore(content, maxLength) { + if (!content || typeof content !== "string") { + return ""; + } + + // Read allowed domains from environment variable + const allowedDomainsEnv = process.env.GH_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + + let allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + + // Extract and add GitHub domains from GitHub context URLs + // This handles GitHub Enterprise deployments with custom domains + const githubServerUrl = process.env.GITHUB_SERVER_URL; + const githubApiUrl = process.env.GITHUB_API_URL; + + if (githubServerUrl) { + const serverDomains = extractDomainsFromUrl(githubServerUrl); + allowedDomains = allowedDomains.concat(serverDomains); + } + + if (githubApiUrl) { + const apiDomains = extractDomainsFromUrl(githubApiUrl); + allowedDomains = allowedDomains.concat(apiDomains); + } + + // Remove duplicates + allowedDomains = [...new Set(allowedDomains)]; + + let sanitized = content; + + // Remove ANSI escape sequences and control characters early + // This must happen before mention neutralization to avoid creating bare mentions + // when control characters are removed between @ and username + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + // Remove control characters except newlines (\n), tabs (\t), and carriage returns (\r) + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + + // Neutralize commands at the start of text (e.g., /bot-name) + sanitized = neutralizeCommands(sanitized); + + // Neutralize ALL @mentions (no filtering in core version) + sanitized = neutralizeAllMentions(sanitized); + + // Remove XML comments first + sanitized = removeXmlComments(sanitized); + + // Convert XML tags to parentheses format to prevent injection + sanitized = convertXmlTags(sanitized); + + // URI filtering - replace non-https protocols with "(redacted)" + sanitized = sanitizeUrlProtocols(sanitized); + + // Domain filtering for HTTPS URIs + sanitized = sanitizeUrlDomains(sanitized, allowedDomains); + + // Check line count before length to provide more specific truncation message + const lines = sanitized.split("\n"); + const maxLines = 65000; + maxLength = maxLength || 524288; + + // If content has too many lines, truncate by lines (primary limit) + if (lines.length > maxLines) { + const truncationMsg = "\n[Content truncated due to line count]"; + const truncatedLines = lines.slice(0, maxLines).join("\n") + truncationMsg; + + // If still too long after line truncation, shorten but keep the line count message + if (truncatedLines.length > maxLength) { + sanitized = truncatedLines.substring(0, maxLength - truncationMsg.length) + truncationMsg; + } else { + sanitized = truncatedLines; + } + } else if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + + // Neutralize common bot trigger phrases + sanitized = neutralizeBotTriggers(sanitized); + + // Trim excessive whitespace + return sanitized.trim(); + + /** + * Remove unknown domains + * @param {string} s - The string to process + * @param {string[]} allowed - List of allowed domains + * @returns {string} The string with unknown domains redacted + */ + function sanitizeUrlDomains(s, allowed) { + // Match HTTPS URLs with optional port and path + // This regex is designed to: + // 1. Match https:// URIs with explicit protocol + // 2. Capture the hostname/domain + // 3. Allow optional port (:8080) + // 4. Allow optional path and query string (but not trailing commas/periods) + // 5. Stop before another https:// URL in query params (using negative lookahead) + const httpsUrlRegex = /https:\/\/([\w.-]+(?::\d+)?)(\/(?:(?!https:\/\/)[^\s,])*)?/gi; + + return s.replace(httpsUrlRegex, (match, hostnameWithPort, pathPart) => { + // Extract just the hostname (remove port if present) + const hostname = hostnameWithPort.split(":")[0].toLowerCase(); + pathPart = pathPart || ""; + + // Check if domain is in the allowed list or is a subdomain of an allowed domain + const isAllowed = allowed.some(allowedDomain => { + const normalizedAllowed = allowedDomain.toLowerCase(); + + // Exact match + if (hostname === normalizedAllowed) { + return true; + } + + // Wildcard match (*.example.com matches subdomain.example.com) + if (normalizedAllowed.startsWith("*.")) { + const baseDomain = normalizedAllowed.substring(2); // Remove *. + return hostname.endsWith("." + baseDomain) || hostname === baseDomain; + } + + // Subdomain match (example.com matches subdomain.example.com) + return hostname.endsWith("." + normalizedAllowed); + }); + + if (isAllowed) { + return match; // Keep the full URL as-is + } else { + // Redact the domain but preserve the protocol and structure for debugging + const truncated = hostname.length > 12 ? hostname.substring(0, 12) + "..." : hostname; + if (typeof core !== "undefined" && core.info) { + core.info(`Redacted URL: ${truncated}`); + } + if (typeof core !== "undefined" && core.debug) { + core.debug(`Redacted URL (full): ${match}`); + } + addRedactedDomain(hostname); + return "(redacted)"; + } + }); + } + + /** + * Sanitize URL protocols - replace non-https with (redacted) + * @param {string} s - The string to process + * @returns {string} The string with non-https protocols redacted + */ + function sanitizeUrlProtocols(s) { + // Match common non-https protocols + // This regex matches: protocol://domain or protocol:path or incomplete protocol:// + // Examples: http://, ftp://, file://, data:, javascript:, mailto:, tel:, ssh://, git:// + // The regex also matches incomplete protocols like "http://" or "ftp://" without a domain + // Note: No word boundary check to catch protocols even when preceded by word characters + return s.replace(/((?:http|ftp|file|ssh|git):\/\/([\w.-]*)(?:[^\s]*)|(?:data|javascript|vbscript|about|mailto|tel):[^\s]+)/gi, (match, _fullMatch, domain) => { + // Extract domain for http/ftp/file/ssh/git protocols + if (domain) { + const domainLower = domain.toLowerCase(); + const truncated = domainLower.length > 12 ? domainLower.substring(0, 12) + "..." : domainLower; + if (typeof core !== "undefined" && core.info) { + core.info(`Redacted URL: ${truncated}`); + } + if (typeof core !== "undefined" && core.debug) { + core.debug(`Redacted URL (full): ${match}`); + } + addRedactedDomain(domainLower); + } else { + // For other protocols (data:, javascript:, etc.), track the protocol itself + const protocolMatch = match.match(/^([^:]+):/); + if (protocolMatch) { + const protocol = protocolMatch[1] + ":"; + // Truncate the matched URL for logging (keep first 12 chars + "...") + const truncated = match.length > 12 ? match.substring(0, 12) + "..." : match; + if (typeof core !== "undefined" && core.info) { + core.info(`Redacted URL: ${truncated}`); + } + if (typeof core !== "undefined" && core.debug) { + core.debug(`Redacted URL (full): ${match}`); + } + addRedactedDomain(protocol); + } + } + return "(redacted)"; + }); + } + + /** + * Neutralizes commands at the start of text by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized commands + */ + function neutralizeCommands(s) { + const commandName = process.env.GH_AW_COMMAND; + if (!commandName) { + return s; + } + + // Escape special regex characters in command name + const escapedCommand = commandName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + + // Neutralize /command at the start of text (with optional leading whitespace) + // Only match at the start of the string or after leading whitespace + return s.replace(new RegExp(`^(\\s*)/(${escapedCommand})\\b`, "i"), "$1`/$2`"); + } + + /** + * Neutralizes ALL @mentions by wrapping them in backticks + * This is the core version without any filtering + * @param {string} s - The string to process + * @returns {string} The string with neutralized mentions + */ + function neutralizeAllMentions(s) { + // Replace @name or @org/team outside code with `@name` + // No filtering - all mentions are neutralized + 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) => { + // Log when a mention is escaped to help debug issues + if (typeof core !== "undefined" && core.info) { + core.info(`Escaped mention: @${p2} (not in allowed list)`); + } + return `${p1}\`@${p2}\``; + }); + } + + /** + * Removes XML comments from content + * @param {string} s - The string to process + * @returns {string} The string with XML comments removed + */ + function removeXmlComments(s) { + // Remove and malformed + return s.replace(//g, "").replace(//g, ""); + } + + /** + * Converts XML/HTML tags to parentheses format to prevent injection + * @param {string} s - The string to process + * @returns {string} The string with XML tags converted to parentheses + */ + function convertXmlTags(s) { + // Allow safe HTML tags: b, blockquote, br, code, details, em, h1–h6, hr, i, li, ol, p, pre, strong, sub, summary, sup, table, tbody, td, th, thead, tr, ul + const allowedTags = ["b", "blockquote", "br", "code", "details", "em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "li", "ol", "p", "pre", "strong", "sub", "summary", "sup", "table", "tbody", "td", "th", "thead", "tr", "ul"]; + + // First, process CDATA sections specially - convert tags inside them and the CDATA markers + s = s.replace(//g, (match, content) => { + // Convert tags inside CDATA content + const convertedContent = content.replace(/<(\/?[A-Za-z][A-Za-z0-9]*(?:[^>]*?))>/g, "($1)"); + // Return with CDATA markers also converted to parentheses + return `(![CDATA[${convertedContent}]])`; + }); + + // Convert opening tags: or to (tag) or (tag attr="value") + // Convert closing tags: to (/tag) + // Convert self-closing tags: or to (tag/) or (tag /) + // But preserve allowed safe tags + return s.replace(/<(\/?[A-Za-z!][^>]*?)>/g, (match, tagContent) => { + // Extract tag name from the content (handle closing tags and attributes) + const tagNameMatch = tagContent.match(/^\/?\s*([A-Za-z][A-Za-z0-9]*)/); + if (tagNameMatch) { + const tagName = tagNameMatch[1].toLowerCase(); + if (allowedTags.includes(tagName)) { + return match; // Preserve allowed tags + } + } + return `(${tagContent})`; // Convert other tags to parentheses + }); + } + + /** + * 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}\``); + } +} + +module.exports = { + sanitizeContentCore, + getRedactedDomains, + addRedactedDomain, + clearRedactedDomains, + writeRedactedDomainsLog, + extractDomainsFromUrl, +}; diff --git a/pkg/workflow/js/sanitize_content_old.cjs b/pkg/workflow/js/sanitize_content_old.cjs index 133e3bcb381..e75392206d8 100644 --- a/pkg/workflow/js/sanitize_content_old.cjs +++ b/pkg/workflow/js/sanitize_content_old.cjs @@ -1 +1,404 @@ -const redactedDomains=[];function getRedactedDomains(){return[...redactedDomains]}function clearRedactedDomains(){redactedDomains.length=0}function writeRedactedDomainsLog(filePath){if(0===redactedDomains.length)return null;const fs=require("fs"),targetPath=filePath||"/tmp/gh-aw/redacted-urls.log",dir=require("path").dirname(targetPath);return fs.existsSync(dir)||fs.mkdirSync(dir,{recursive:!0}),fs.writeFileSync(targetPath,redactedDomains.join("\n")+"\n"),targetPath}function extractDomainsFromUrl(url){if(!url||"string"!=typeof url)return[];try{const hostname=new URL(url).hostname.toLowerCase(),domains=[hostname];return"github.com"===hostname?(domains.push("api.github.com"),domains.push("raw.githubusercontent.com"),domains.push("*.githubusercontent.com")):hostname.startsWith("api.")||(domains.push("api."+hostname),domains.push("raw."+hostname)),domains}catch(e){return[]}}function sanitizeContent(content,maxLengthOrOptions){let maxLength,allowedAliasesLowercase=[];if("number"==typeof maxLengthOrOptions?maxLength=maxLengthOrOptions:maxLengthOrOptions&&"object"==typeof maxLengthOrOptions&&(maxLength=maxLengthOrOptions.maxLength,allowedAliasesLowercase=(maxLengthOrOptions.allowedAliases||[]).map(alias=>alias.toLowerCase())),!content||"string"!=typeof content)return"";const allowedDomainsEnv=process.env.GH_AW_ALLOWED_DOMAINS;let allowedDomains=allowedDomainsEnv?allowedDomainsEnv.split(",").map(d=>d.trim()).filter(d=>d):["github.com","github.io","githubusercontent.com","githubassets.com","github.dev","codespaces.new"];const githubServerUrl=process.env.GITHUB_SERVER_URL,githubApiUrl=process.env.GITHUB_API_URL;if(githubServerUrl){const serverDomains=extractDomainsFromUrl(githubServerUrl);allowedDomains=allowedDomains.concat(serverDomains)}if(githubApiUrl){const apiDomains=extractDomainsFromUrl(githubApiUrl);allowedDomains=allowedDomains.concat(apiDomains)}allowedDomains=[...new Set(allowedDomains)];let sanitized=content;sanitized=function(s){const commandName=process.env.GH_AW_COMMAND;if(!commandName)return s;const escapedCommand=commandName.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");return s.replace(new RegExp(`^(\\s*)/(${escapedCommand})\\b`,"i"),"$1`/$2`")}(sanitized),sanitized=sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g,(_m,p1,p2)=>allowedAliasesLowercase.includes(p2.toLowerCase())?`${p1}@${p2}`:(core.info(`Escaped mention: @${p2} (not in allowed list)`),`${p1}\`@${p2}\``)),sanitized=sanitized.replace(//g,"").replace(//g,""),sanitized=function(s){const allowedTags=["b","blockquote","br","code","details","em","h1","h2","h3","h4","h5","h6","hr","i","li","ol","p","pre","strong","sub","summary","sup","table","tbody","td","th","thead","tr","ul"];return s=s.replace(//g,(match,content)=>`(![CDATA[${content.replace(/<(\/?[A-Za-z][A-Za-z0-9]*(?:[^>]*?))>/g,"($1)")}]])`),s.replace(/<(\/?[A-Za-z!][^>]*?)>/g,(match,tagContent)=>{const tagNameMatch=tagContent.match(/^\/?\s*([A-Za-z][A-Za-z0-9]*)/);if(tagNameMatch){const tagName=tagNameMatch[1].toLowerCase();if(allowedTags.includes(tagName))return match}return`(${tagContent})`})}(sanitized),sanitized=sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g,""),sanitized=sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g,""),sanitized=function(s){return s.replace(/(?&\x00-\x1f]+/g,(match,protocol)=>{if("https"===protocol.toLowerCase())return match;if(match.includes("::"))return match;if(match.includes("://")){const domainMatch=match.match(/^[^:]+:\/\/([^\/\s?#]+)/),domain=domainMatch?domainMatch[1]:match,truncated=domain.length>12?domain.substring(0,12)+"...":domain;return core.info(`Redacted URL: ${truncated}`),core.debug(`Redacted URL (full): ${match}`),redactedDomains.push(domain),"(redacted)"}if(["javascript","data","vbscript","file","about","mailto","tel","ssh","ftp"].includes(protocol.toLowerCase())){const truncated=match.length>12?match.substring(0,12)+"...":match;return core.info(`Redacted URL: ${truncated}`),core.debug(`Redacted URL (full): ${match}`),redactedDomains.push(protocol+":"),"(redacted)"}return match})}(sanitized),sanitized=function sanitizeUrlDomains(s){return s=s.replace(/\bhttps:\/\/([^\s\])}'"<>&\x00-\x1f,;]+)/gi,(match,rest)=>{const hostname=rest.split(/[\/:\?#]/)[0].toLowerCase();if(allowedDomains.some(allowedDomain=>{const normalizedAllowed=allowedDomain.toLowerCase();return hostname===normalizedAllowed||hostname.endsWith("."+normalizedAllowed)}))return match;const domain=hostname,truncated=domain.length>12?domain.substring(0,12)+"...":domain;core.info(`Redacted URL: ${truncated}`),core.debug(`Redacted URL (full): ${match}`),redactedDomains.push(domain);const urlParts=match.split(/([?&#])/);let result="(redacted)";for(let i=1;i65e3){const truncationMsg="\n[Content truncated due to line count]",truncatedLines=lines.slice(0,65e3).join("\n")+truncationMsg;sanitized=truncatedLines.length>maxLength?truncatedLines.substring(0,maxLength-truncationMsg.length)+truncationMsg:truncatedLines}else sanitized.length>maxLength&&(sanitized=sanitized.substring(0,maxLength)+"\n[Content truncated due to length]");return sanitized=function(s){return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi,(match,action,ref)=>`\`${action} #${ref}\``)}(sanitized),sanitized.trim()}module.exports={sanitizeContent,getRedactedDomains,clearRedactedDomains,writeRedactedDomainsLog}; \ No newline at end of file +// @ts-check +/** + * Shared sanitization utilities for GitHub Actions output + * This module provides functions for sanitizing content to prevent security issues + * and unintended side effects in GitHub Actions workflows. + */ + +/** + * Module-level set to collect redacted URL domains across sanitization calls. + * @type {string[]} + */ +const redactedDomains = []; + +/** + * Gets the list of redacted URL domains collected during sanitization. + * @returns {string[]} Array of redacted domain strings + */ +function getRedactedDomains() { + return [...redactedDomains]; +} + +/** + * Clears the list of redacted URL domains. + * Useful for testing or resetting state between operations. + */ +function clearRedactedDomains() { + redactedDomains.length = 0; +} + +/** + * Writes the collected redacted URL domains to a log file. + * Only creates the file if there are redacted domains. + * @param {string} [filePath] - Path to write the log file. Defaults to /tmp/gh-aw/redacted-urls.log + * @returns {string|null} The file path if written, null if no domains to write + */ +function writeRedactedDomainsLog(filePath) { + if (redactedDomains.length === 0) { + return null; + } + + const fs = require("fs"); + const path = require("path"); + const targetPath = filePath || "/tmp/gh-aw/redacted-urls.log"; + + // Ensure directory exists + const dir = path.dirname(targetPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + // Write domains to file, one per line + fs.writeFileSync(targetPath, redactedDomains.join("\n") + "\n"); + + return targetPath; +} + +/** + * Extract domains from a URL and return an array of domain variations + * @param {string} url - The URL to extract domains from + * @returns {string[]} Array of domain variations + */ +function extractDomainsFromUrl(url) { + if (!url || typeof url !== "string") { + return []; + } + + try { + // Parse the URL + const urlObj = new URL(url); + const hostname = urlObj.hostname.toLowerCase(); + + // Return both the exact hostname and common variations + const domains = [hostname]; + + // For github.com, add api and raw content domain variations + if (hostname === "github.com") { + domains.push("api.github.com"); + domains.push("raw.githubusercontent.com"); + domains.push("*.githubusercontent.com"); + } + // For custom GitHub Enterprise domains, add api. prefix and raw content variations + else if (!hostname.startsWith("api.")) { + domains.push("api." + hostname); + // For GitHub Enterprise, raw content is typically served from raw.hostname + domains.push("raw." + hostname); + } + + return domains; + } catch (e) { + // Invalid URL, return empty array + return []; + } +} + +/** + * @typedef {Object} SanitizeOptions + * @property {number} [maxLength] - Maximum length of content (default: 524288) + * @property {string[]} [allowedAliases] - List of aliases (@mentions) that should not be neutralized + */ + +/** + * Sanitizes content for safe output in GitHub Actions + * @param {string} content - The content to sanitize + * @param {number | SanitizeOptions} [maxLengthOrOptions] - Maximum length of content (default: 524288) or options object + * @returns {string} The sanitized content + */ +function sanitizeContent(content, maxLengthOrOptions) { + // Handle both old signature (maxLength) and new signature (options object) + /** @type {number | undefined} */ + let maxLength; + /** @type {string[]} */ + let allowedAliasesLowercase = []; + + if (typeof maxLengthOrOptions === "number") { + maxLength = maxLengthOrOptions; + } else if (maxLengthOrOptions && typeof maxLengthOrOptions === "object") { + maxLength = maxLengthOrOptions.maxLength; + // Pre-process allowed aliases to lowercase for efficient comparison + allowedAliasesLowercase = (maxLengthOrOptions.allowedAliases || []).map(alias => alias.toLowerCase()); + } + if (!content || typeof content !== "string") { + return ""; + } + + // Read allowed domains from environment variable + const allowedDomainsEnv = process.env.GH_AW_ALLOWED_DOMAINS; + const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"]; + + let allowedDomains = allowedDomainsEnv + ? allowedDomainsEnv + .split(",") + .map(d => d.trim()) + .filter(d => d) + : defaultAllowedDomains; + + // Extract and add GitHub domains from GitHub context URLs + // This handles GitHub Enterprise deployments with custom domains + const githubServerUrl = process.env.GITHUB_SERVER_URL; + const githubApiUrl = process.env.GITHUB_API_URL; + + if (githubServerUrl) { + const serverDomains = extractDomainsFromUrl(githubServerUrl); + allowedDomains = allowedDomains.concat(serverDomains); + } + + if (githubApiUrl) { + const apiDomains = extractDomainsFromUrl(githubApiUrl); + allowedDomains = allowedDomains.concat(apiDomains); + } + + // Remove duplicates + allowedDomains = [...new Set(allowedDomains)]; + + let sanitized = content; + + // Neutralize commands at the start of text (e.g., /bot-name) + sanitized = neutralizeCommands(sanitized); + + // Neutralize @mentions to prevent unintended notifications + sanitized = neutralizeMentions(sanitized); + + // Remove XML comments first + sanitized = removeXmlComments(sanitized); + + // Convert XML tags to parentheses format to prevent injection + sanitized = convertXmlTags(sanitized); + + // Remove ANSI escape sequences + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + + // Remove control characters (except newlines and tabs) + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + + // URI filtering - replace non-https protocols with "(redacted)" + sanitized = sanitizeUrlProtocols(sanitized); + + // Domain filtering for HTTPS URIs + sanitized = sanitizeUrlDomains(sanitized); + + // Check line count before length to provide more specific truncation message + const lines = sanitized.split("\n"); + const maxLines = 65000; + maxLength = maxLength || 524288; + + // If content has too many lines, truncate by lines (primary limit) + if (lines.length > maxLines) { + const truncationMsg = "\n[Content truncated due to line count]"; + const truncatedLines = lines.slice(0, maxLines).join("\n") + truncationMsg; + + // If still too long after line truncation, shorten but keep the line count message + if (truncatedLines.length > maxLength) { + sanitized = truncatedLines.substring(0, maxLength - truncationMsg.length) + truncationMsg; + } else { + sanitized = truncatedLines; + } + } else if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]"; + } + + // 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) { + // First pass: match all HTTPS URLs and process them + // We need to handle URLs that might contain other URLs in query parameters + s = s.replace(/\bhttps:\/\/([^\s\])}'"<>&\x00-\x1f,;]+)/gi, (match, rest) => { + // Extract the hostname part (before first slash, colon, or other delimiter) + const hostname = rest.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); + }); + + if (isAllowed) { + return match; // Keep allowed URLs as-is + } + + // Log the redaction and collect the domain + const domain = hostname; + const truncated = domain.length > 12 ? domain.substring(0, 12) + "..." : domain; + core.info(`Redacted URL: ${truncated}`); + core.debug(`Redacted URL (full): ${match}`); + redactedDomains.push(domain); + + // For disallowed URLs, check if there are any allowed URLs in the query/fragment + // and preserve those while redacting the main URL + const urlParts = match.split(/([?&#])/); + let result = "(redacted)"; // Redact the main domain + + // Process query/fragment parts to preserve any allowed URLs within them + for (let i = 1; i < urlParts.length; i++) { + if (urlParts[i].match(/^[?&#]$/)) { + result += urlParts[i]; // Keep separators + } else { + // Recursively process this part to preserve any allowed URLs + result += sanitizeUrlDomains(urlParts[i]); + } + } + + return result; + }); + + return s; + } + + /** + * 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 protocol patterns but avoid command-line flags, file paths, and namespaces + // Protocol patterns typically have :// or are well-known schemes followed by : + // Use negative lookbehind to exclude patterns preceded by - (command flags) + // Match only patterns that look like actual protocols + return s.replace(/(?&\x00-\x1f]+/g, (match, protocol) => { + // Allow https (case insensitive), redact everything else + // But only if it looks like a URL (has :// or is followed by non-colon content) + if (protocol.toLowerCase() === "https") { + return match; + } + + // Allow if it looks like a file path or namespace (::) + if (match.includes("::")) { + return match; + } + + // Redact if it has :// (definite protocol) + if (match.includes("://")) { + // Log the redaction and collect the domain + // Extract domain from URL + const domainMatch = match.match(/^[^:]+:\/\/([^\/\s?#]+)/); + const domain = domainMatch ? domainMatch[1] : match; + const truncated = domain.length > 12 ? domain.substring(0, 12) + "..." : domain; + core.info(`Redacted URL: ${truncated}`); + core.debug(`Redacted URL (full): ${match}`); + redactedDomains.push(domain); + return "(redacted)"; + } + + // Redact well-known dangerous protocols like javascript:, data:, etc. + const dangerousProtocols = ["javascript", "data", "vbscript", "file", "about", "mailto", "tel", "ssh", "ftp"]; + if (dangerousProtocols.includes(protocol.toLowerCase())) { + // Log the redaction and collect the domain + // For dangerous protocols without ://, show protocol and beginning of content + const truncated = match.length > 12 ? match.substring(0, 12) + "..." : match; + core.info(`Redacted URL: ${truncated}`); + core.debug(`Redacted URL (full): ${match}`); + redactedDomains.push(protocol + ":"); + return "(redacted)"; + } + + // Otherwise preserve (could be file:path, namespace:thing, etc.) + return match; + }); + } + + /** + * Neutralizes commands at the start of text by wrapping them in backticks + * @param {string} s - The string to process + * @returns {string} The string with neutralized commands + */ + function neutralizeCommands(s) { + const commandName = process.env.GH_AW_COMMAND; + if (!commandName) { + return s; + } + + // Escape special regex characters in command name + const escapedCommand = commandName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + + // Neutralize /command at the start of text (with optional leading whitespace) + // Only match at the start of the string or after leading whitespace + return s.replace(new RegExp(`^(\\s*)/(${escapedCommand})\\b`, "i"), "$1`/$2`"); + } + + /** + * Neutralizes @mentions by wrapping them in backticks + * Skips mentions that are in the allowedAliases list + * @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` + // Skip mentions that are in the allowed aliases list + 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) => { + // Check if this mention is in the allowed aliases list (case-insensitive) + // allowedAliasesLowercase is pre-processed to lowercase for efficient comparison + const isAllowed = allowedAliasesLowercase.includes(p2.toLowerCase()); + if (isAllowed) { + return `${p1}@${p2}`; // Keep the original mention + } + // Log when a mention is escaped to help debug issues + core.info(`Escaped mention: @${p2} (not in allowed list)`); + return `${p1}\`@${p2}\``; // Neutralize the mention + }); + } + + /** + * Removes XML comments from content + * @param {string} s - The string to process + * @returns {string} The string with XML comments removed + */ + function removeXmlComments(s) { + // Remove and malformed + return s.replace(//g, "").replace(//g, ""); + } + + /** + * Converts XML/HTML tags to parentheses format to prevent injection + * @param {string} s - The string to process + * @returns {string} The string with XML tags converted to parentheses + */ + function convertXmlTags(s) { + // Allow safe HTML tags: b, blockquote, br, code, details, em, h1–h6, hr, i, li, ol, p, pre, strong, sub, summary, sup, table, tbody, td, th, thead, tr, ul + const allowedTags = ["b", "blockquote", "br", "code", "details", "em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "li", "ol", "p", "pre", "strong", "sub", "summary", "sup", "table", "tbody", "td", "th", "thead", "tr", "ul"]; + + // First, process CDATA sections specially - convert tags inside them and the CDATA markers + s = s.replace(//g, (match, content) => { + // Convert tags inside CDATA content + const convertedContent = content.replace(/<(\/?[A-Za-z][A-Za-z0-9]*(?:[^>]*?))>/g, "($1)"); + // Return with CDATA markers also converted to parentheses + return `(![CDATA[${convertedContent}]])`; + }); + + // Convert opening tags: or to (tag) or (tag attr="value") + // Convert closing tags: to (/tag) + // Convert self-closing tags: or to (tag/) or (tag /) + // But preserve allowed safe tags + return s.replace(/<(\/?[A-Za-z!][^>]*?)>/g, (match, tagContent) => { + // Extract tag name from the content (handle closing tags and attributes) + const tagNameMatch = tagContent.match(/^\/?\s*([A-Za-z][A-Za-z0-9]*)/); + if (tagNameMatch) { + const tagName = tagNameMatch[1].toLowerCase(); + if (allowedTags.includes(tagName)) { + return match; // Preserve allowed tags + } + } + return `(${tagContent})`; // Convert other tags to parentheses + }); + } + + /** + * 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}\``); + } +} + +module.exports = { sanitizeContent, getRedactedDomains, clearRedactedDomains, writeRedactedDomainsLog }; diff --git a/pkg/workflow/js/sanitize_incoming_text.cjs b/pkg/workflow/js/sanitize_incoming_text.cjs index 29c9f68a9eb..0874ed4a20c 100644 --- a/pkg/workflow/js/sanitize_incoming_text.cjs +++ b/pkg/workflow/js/sanitize_incoming_text.cjs @@ -1 +1,27 @@ -const{sanitizeContentCore,writeRedactedDomainsLog}=require("./sanitize_content_core.cjs");function sanitizeIncomingText(content,maxLength){return sanitizeContentCore(content,maxLength)}module.exports={sanitizeIncomingText,writeRedactedDomainsLog}; \ No newline at end of file +// @ts-check +/** + * Slimmed-down sanitization for incoming text (compute_text) + * This version does NOT include mention filtering - all @mentions are escaped + */ + +const { sanitizeContentCore, writeRedactedDomainsLog } = require("./sanitize_content_core.cjs"); + +/** + * Sanitizes incoming text content without selective mention filtering + * All @mentions are escaped to prevent unintended notifications + * + * Uses the core sanitization functions directly to minimize bundle size. + * + * @param {string} content - The content to sanitize + * @param {number} [maxLength] - Maximum length of content (default: 524288) + * @returns {string} The sanitized content with all mentions escaped + */ +function sanitizeIncomingText(content, maxLength) { + // Call core sanitization which neutralizes all mentions + return sanitizeContentCore(content, maxLength); +} + +module.exports = { + sanitizeIncomingText, + writeRedactedDomainsLog, +}; diff --git a/pkg/workflow/js/sanitize_label_content.cjs b/pkg/workflow/js/sanitize_label_content.cjs index 5917117bffa..938c1994df4 100644 --- a/pkg/workflow/js/sanitize_label_content.cjs +++ b/pkg/workflow/js/sanitize_label_content.cjs @@ -1 +1,29 @@ -function sanitizeLabelContent(content){if(!content||"string"!=typeof content)return"";let sanitized=content.trim();return sanitized=sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g,""),sanitized=sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g,""),sanitized=sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g,(_m,p1,p2)=>`${p1}\`@${p2}\``),sanitized=sanitized.replace(/[<>&'"]/g,""),sanitized.trim()}module.exports={sanitizeLabelContent}; \ No newline at end of file +// @ts-check +/** + * Sanitize label content for GitHub API + * Removes control characters, ANSI codes, and neutralizes @mentions + * @module sanitize_label_content + */ + +/** + * Sanitizes label content by removing control characters, ANSI escape codes, + * and neutralizing @mentions to prevent unintended notifications. + * + * @param {string} content - The label content to sanitize + * @returns {string} The sanitized label content + */ +function sanitizeLabelContent(content) { + if (!content || typeof content !== "string") { + return ""; + } + let sanitized = content.trim(); + // Remove ANSI escape sequences FIRST (before removing control chars) + sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, ""); + // Then remove control characters (except newlines and tabs) + sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``); + sanitized = sanitized.replace(/[<>&'"]/g, ""); + return sanitized.trim(); +} + +module.exports = { sanitizeLabelContent }; diff --git a/pkg/workflow/js/sanitize_label_content.test.cjs b/pkg/workflow/js/sanitize_label_content.test.cjs index 679a8769cc4..8243247df1b 100644 --- a/pkg/workflow/js/sanitize_label_content.test.cjs +++ b/pkg/workflow/js/sanitize_label_content.test.cjs @@ -1 +1,94 @@ -import{describe,it,expect}from"vitest";const{sanitizeLabelContent}=require("./sanitize_label_content.cjs");describe("sanitize_label_content.cjs",()=>{describe("sanitizeLabelContent",()=>{it("should return empty string for null input",()=>{expect(sanitizeLabelContent(null)).toBe("")}),it("should return empty string for undefined input",()=>{expect(sanitizeLabelContent(void 0)).toBe("")}),it("should return empty string for non-string input",()=>{expect(sanitizeLabelContent(123)).toBe(""),expect(sanitizeLabelContent({})).toBe(""),expect(sanitizeLabelContent([])).toBe("")}),it("should trim whitespace from input",()=>{expect(sanitizeLabelContent(" test ")).toBe("test"),expect(sanitizeLabelContent("\n\ttest\n\t")).toBe("test")}),it("should remove control characters",()=>{expect(sanitizeLabelContent("test\0\blabel")).toBe("testlabel")}),it("should remove DEL character (0x7F)",()=>{expect(sanitizeLabelContent("testlabel")).toBe("testlabel")}),it("should preserve newline character",()=>{expect(sanitizeLabelContent("test\nlabel")).toBe("test\nlabel")}),it("should remove ANSI escape codes",()=>{expect(sanitizeLabelContent("red text")).toBe("red text")}),it("should remove various ANSI codes",()=>{expect(sanitizeLabelContent("Bold GreenUnderline")).toBe("Bold GreenUnderline")}),it("should neutralize @mentions by wrapping in backticks",()=>{expect(sanitizeLabelContent("Hello @user")).toBe("Hello `@user`"),expect(sanitizeLabelContent("@user said something")).toBe("`@user` said something")}),it("should neutralize @org/team mentions",()=>{expect(sanitizeLabelContent("Hello @myorg/myteam")).toBe("Hello `@myorg/myteam`")}),it("should not neutralize @mentions already in backticks",()=>{expect(sanitizeLabelContent("Already `@user` handled")).toBe("Already `@user` handled")}),it("should neutralize multiple @mentions",()=>{expect(sanitizeLabelContent("@user1 and @user2 are here")).toBe("`@user1` and `@user2` are here")}),it("should remove HTML special characters",()=>{expect(sanitizeLabelContent("test<>&'\"label")).toBe("testlabel")}),it("should remove less-than signs",()=>{expect(sanitizeLabelContent("a < b")).toBe("a b")}),it("should remove greater-than signs",()=>{expect(sanitizeLabelContent("a > b")).toBe("a b")}),it("should remove ampersands",()=>{expect(sanitizeLabelContent("test & label")).toBe("test label")}),it("should remove single and double quotes",()=>{expect(sanitizeLabelContent('test\'s "label"')).toBe("tests label")}),it("should handle complex input with multiple sanitizations",()=>{expect(sanitizeLabelContent(" @user red test&label ")).toBe("`@user` red tag testlabel")}),it("should handle empty string input",()=>{expect(sanitizeLabelContent("")).toBe("")}),it("should handle whitespace-only input",()=>{expect(sanitizeLabelContent(" \n\t ")).toBe("")}),it("should preserve normal alphanumeric characters",()=>{expect(sanitizeLabelContent("bug123")).toBe("bug123"),expect(sanitizeLabelContent("feature-request")).toBe("feature-request")}),it("should preserve hyphens and underscores",()=>{expect(sanitizeLabelContent("test-label_123")).toBe("test-label_123")}),it("should handle consecutive control characters",()=>{expect(sanitizeLabelContent("test\0label")).toBe("testlabel")}),it("should handle @mentions at various positions",()=>{expect(sanitizeLabelContent("start @user end")).toBe("start `@user` end"),expect(sanitizeLabelContent("@user at start")).toBe("`@user` at start"),expect(sanitizeLabelContent("at end @user")).toBe("at end `@user`")}),it("should not treat email-like patterns as @mentions after alphanumerics",()=>{expect(sanitizeLabelContent("email@example.com")).toBe("email@example.com")}),it("should handle username edge cases",()=>{expect(sanitizeLabelContent("@a")).toBe("`@a`"),expect(sanitizeLabelContent("@user-name-123")).toBe("`@user-name-123`")}),it("should combine all sanitization rules correctly",()=>{expect(sanitizeLabelContent(' @user says & "goodbye" ')).toBe("`@user` says hello goodbye")})})}); \ No newline at end of file +import { describe, it, expect } from "vitest"; +// Import the function to test +const { sanitizeLabelContent } = require("./sanitize_label_content.cjs"); +describe("sanitize_label_content.cjs", () => { + describe("sanitizeLabelContent", () => { + (it("should return empty string for null input", () => { + expect(sanitizeLabelContent(null)).toBe(""); + }), + it("should return empty string for undefined input", () => { + expect(sanitizeLabelContent(void 0)).toBe(""); + }), + it("should return empty string for non-string input", () => { + (expect(sanitizeLabelContent(123)).toBe(""), expect(sanitizeLabelContent({})).toBe(""), expect(sanitizeLabelContent([])).toBe("")); + }), + it("should trim whitespace from input", () => { + (expect(sanitizeLabelContent(" test ")).toBe("test"), expect(sanitizeLabelContent("\n\ttest\n\t")).toBe("test")); + }), + it("should remove control characters", () => { + expect(sanitizeLabelContent("test\0\blabel")).toBe("testlabel"); + }), + it("should remove DEL character (0x7F)", () => { + expect(sanitizeLabelContent("testlabel")).toBe("testlabel"); + }), + it("should preserve newline character", () => { + expect(sanitizeLabelContent("test\nlabel")).toBe("test\nlabel"); + }), + it("should remove ANSI escape codes", () => { + expect(sanitizeLabelContent("red text")).toBe("red text"); + }), + it("should remove various ANSI codes", () => { + expect(sanitizeLabelContent("Bold GreenUnderline")).toBe("Bold GreenUnderline"); + }), + it("should neutralize @mentions by wrapping in backticks", () => { + (expect(sanitizeLabelContent("Hello @user")).toBe("Hello `@user`"), expect(sanitizeLabelContent("@user said something")).toBe("`@user` said something")); + }), + it("should neutralize @org/team mentions", () => { + expect(sanitizeLabelContent("Hello @myorg/myteam")).toBe("Hello `@myorg/myteam`"); + }), + it("should not neutralize @mentions already in backticks", () => { + expect(sanitizeLabelContent("Already `@user` handled")).toBe("Already `@user` handled"); + }), + it("should neutralize multiple @mentions", () => { + expect(sanitizeLabelContent("@user1 and @user2 are here")).toBe("`@user1` and `@user2` are here"); + }), + it("should remove HTML special characters", () => { + expect(sanitizeLabelContent("test<>&'\"label")).toBe("testlabel"); + }), + it("should remove less-than signs", () => { + expect(sanitizeLabelContent("a < b")).toBe("a b"); + }), + it("should remove greater-than signs", () => { + expect(sanitizeLabelContent("a > b")).toBe("a b"); + }), + it("should remove ampersands", () => { + expect(sanitizeLabelContent("test & label")).toBe("test label"); + }), + it("should remove single and double quotes", () => { + expect(sanitizeLabelContent('test\'s "label"')).toBe("tests label"); + }), + it("should handle complex input with multiple sanitizations", () => { + expect(sanitizeLabelContent(" @user red test&label ")).toBe("`@user` red tag testlabel"); + }), + it("should handle empty string input", () => { + expect(sanitizeLabelContent("")).toBe(""); + }), + it("should handle whitespace-only input", () => { + expect(sanitizeLabelContent(" \n\t ")).toBe(""); + }), + it("should preserve normal alphanumeric characters", () => { + (expect(sanitizeLabelContent("bug123")).toBe("bug123"), expect(sanitizeLabelContent("feature-request")).toBe("feature-request")); + }), + it("should preserve hyphens and underscores", () => { + expect(sanitizeLabelContent("test-label_123")).toBe("test-label_123"); + }), + it("should handle consecutive control characters", () => { + expect(sanitizeLabelContent("test\0label")).toBe("testlabel"); + }), + it("should handle @mentions at various positions", () => { + (expect(sanitizeLabelContent("start @user end")).toBe("start `@user` end"), expect(sanitizeLabelContent("@user at start")).toBe("`@user` at start"), expect(sanitizeLabelContent("at end @user")).toBe("at end `@user`")); + }), + it("should not treat email-like patterns as @mentions after alphanumerics", () => { + // The regex has [^\w`] which requires non-word character before @ + // so 'email@' won't match because 'l' is a word character + expect(sanitizeLabelContent("email@example.com")).toBe("email@example.com"); + }), + it("should handle username edge cases", () => { + // Valid GitHub usernames can be 1-39 chars, alphanumeric + hyphens + (expect(sanitizeLabelContent("@a")).toBe("`@a`"), expect(sanitizeLabelContent("@user-name-123")).toBe("`@user-name-123`")); + }), + it("should combine all sanitization rules correctly", () => { + expect(sanitizeLabelContent(' @user says & "goodbye" ')).toBe("`@user` says hello goodbye"); + })); + }); +}); diff --git a/pkg/workflow/js/sanitize_output.test.cjs b/pkg/workflow/js/sanitize_output.test.cjs index d7194eac1d0..b828853f5d8 100644 --- a/pkg/workflow/js/sanitize_output.test.cjs +++ b/pkg/workflow/js/sanitize_output.test.cjs @@ -1 +1,749 @@ -import{describe,it,expect,beforeEach,afterEach,vi}from"vitest";import fs from"fs";import path from"path";const mockCore={debug:vi.fn(),info:vi.fn(),notice:vi.fn(),warning:vi.fn(),error:vi.fn(),setFailed:vi.fn(),setOutput:vi.fn(),exportVariable:vi.fn(),setSecret:vi.fn(),getInput:vi.fn(),getBooleanInput:vi.fn(),getMultilineInput:vi.fn(),getState:vi.fn(),saveState:vi.fn(),startGroup:vi.fn(),endGroup:vi.fn(),group:vi.fn(),addPath:vi.fn(),setCommandEcho:vi.fn(),isDebug:vi.fn().mockReturnValue(!1),getIDToken:vi.fn(),toPlatformPath:vi.fn(),toPosixPath:vi.fn(),toWin32Path:vi.fn(),summary:{addRaw:vi.fn().mockReturnThis(),write:vi.fn().mockResolvedValue()}};global.core=mockCore,describe("sanitize_output.cjs",()=>{let sanitizeScript,sanitizeContentFunction;beforeEach(()=>{vi.clearAllMocks(),delete process.env.GH_AW_SAFE_OUTPUTS,delete process.env.GH_AW_ALLOWED_DOMAINS;const scriptPath=path.join(process.cwd(),"sanitize_output.cjs");sanitizeScript=fs.readFileSync(scriptPath,"utf8");const scriptWithExport=sanitizeScript.replace("await main();","global.testSanitizeContent = sanitizeContent;");eval(scriptWithExport),sanitizeContentFunction=global.testSanitizeContent}),describe("sanitizeContent function",()=>{it("should handle null and undefined inputs",()=>{expect(sanitizeContentFunction(null)).toBe(""),expect(sanitizeContentFunction(void 0)).toBe(""),expect(sanitizeContentFunction("")).toBe("")}),it("should neutralize @mentions by wrapping in backticks",()=>{const result=sanitizeContentFunction("Hello @user and @org/team");expect(result).toContain("`@user`"),expect(result).toContain("`@org/team`")}),it("should not neutralize @mentions inside code blocks",()=>{const result=sanitizeContentFunction("Check `@user` in code and @realuser outside");expect(result).toContain("`@user`"),expect(result).toContain("`@realuser`")}),it("should neutralize bot trigger phrases",()=>{const result=sanitizeContentFunction("This fixes #123 and closes #456. Also resolves #789");expect(result).toContain("`fixes #123`"),expect(result).toContain("`closes #456`"),expect(result).toContain("`resolves #789`")}),it("should remove control characters except newlines and tabs",()=>{const result=sanitizeContentFunction("Hello\0world\f\nNext line\tbad");expect(result).not.toContain("\0"),expect(result).not.toContain("\f"),expect(result).not.toContain(""),expect(result).toContain("\n"),expect(result).toContain("\t")}),it("should convert XML tags to parentheses format",()=>{const result=sanitizeContentFunction('