diff --git a/.changeset/patch-add-terser-formatting.md b/.changeset/patch-add-terser-formatting.md new file mode 100644 index 0000000000..39d9c048f2 --- /dev/null +++ b/.changeset/patch-add-terser-formatting.md @@ -0,0 +1,15 @@ +--- +"gh-aw": patch +--- + +Add a second pass for JavaScript formatting that runs `terser` on `.cjs` files after `prettier` to reduce file size while preserving readability and TypeScript compatibility. + +This change adds the `terser` dependency and integrates it into the `format:cjs` pipeline (prettier → terser → prettier). Files that are TypeScript-checked or contain top-level/dynamic `await` are excluded from terser processing to avoid breaking behavior. + +This is an internal tooling change only (formatting/minification) and does not change runtime behavior or public APIs. + +Summary of impact: +- 14,784 lines removed across 65 files (~49% reduction) due to minification and formatting +- TypeScript type checking preserved +- All tests remain passing + 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 bef2ab45c6..36ab6dfa37 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: { diff --git a/pkg/workflow/js/add_comment.test.cjs b/pkg/workflow/js/add_comment.test.cjs index ef7867c95d..31e2437f7d 100644 --- a/pkg/workflow/js/add_comment.test.cjs +++ b/pkg/workflow/js/add_comment.test.cjs @@ -1,1262 +1,617 @@ 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(), + // 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(!1), + 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() }, }, -}; - -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", - }, - }, -}; - + 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" } } }; // 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", +((global.core = mockCore), + (global.github = mockGithub), + (global.context = mockContext), + describe("add_comment.cjs", () => { + let createCommentScript, 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 = "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(() => { + // 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 + tempFilePath && require("fs").existsSync(tempFilePath) && (require("fs").unlinkSync(tempFilePath), (tempFilePath = void 0)); + }), + 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" }), + 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 }), + // 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\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" }, + ], + })), + // Mock the minimizeComment GraphQL mutation + (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 }), + // 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\x3c!-- workflow-id: test-workflow-789 --\x3e" }] })), + // Mock the minimizeComment GraphQL mutation + (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 }), + // 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\x3c!-- workflow-id: test-workflow-lowercase --\x3e" }] })), + // Mock the minimizeComment GraphQL mutation + (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 }), + // 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); + })); + })); diff --git a/pkg/workflow/js/add_labels.test.cjs b/pkg/workflow/js/add_labels.test.cjs index 066f535208..992f9f1af9 100644 --- a/pkg/workflow/js/add_labels.test.cjs +++ b/pkg/workflow/js/add_labels.test.cjs @@ -1,1088 +1,498 @@ 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(), + // 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(!1), + 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() }, }, -}; - -const mockGithub = { - rest: { - issues: { - addLabels: vi.fn(), - }, - }, -}; - -const mockContext = { - eventName: "issues", - repo: { - owner: "testowner", - repo: "testrepo", - }, - payload: { - issue: { - number: 123, - }, - }, -}; - + mockGithub = { rest: { issues: { addLabels: vi.fn() } } }, + 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 & more'; - const result = sanitizeContentFunction(input); - expect(result).toContain("(script)"); - expect(result).toContain("(/script)"); - expect(result).toContain('"test"'); - expect(result).toContain("& more"); - }); - - it("should handle self-closing XML tags without whitespace", () => { - const input = 'Self-closing:
'; - const result = sanitizeContentFunction(input); - expect(result).toContain("
"); // br is allowed - expect(result).toContain('(img src="test.jpg"/)'); - expect(result).toContain('(meta charset="utf-8"/)'); - }); - - it("should handle self-closing XML tags with whitespace", () => { - const input = 'With spaces:
'; - const result = sanitizeContentFunction(input); - expect(result).toContain("
"); // br is allowed - expect(result).toContain('(img src="test.jpg" /)'); - expect(result).toContain('(meta charset="utf-8" /)'); - }); - - it("should handle XML tags with various whitespace patterns", () => { - const input = 'Various: content text'; - const result = sanitizeContentFunction(input); - expect(result).toContain('(div\tclass="test")content(/div)'); - expect(result).toContain('(span\n id="test")text(/span)'); - }); - - it("should preserve non-XML uses of < and > characters", () => { - const input = "Math: x < y, array[5] > 3, and
content
"; - const result = sanitizeContentFunction(input); - expect(result).toContain("x < y"); - expect(result).toContain("5] > 3"); - expect(result).toContain("(div)content(/div)"); - }); - - it("should handle mixed XML tags and comparison operators", () => { - const input = "Compare: a < b and then plus c > d"; - const result = sanitizeContentFunction(input); - expect(result).toContain("a < b"); - expect(result).toContain("(script)alert(1)(/script)"); - expect(result).toContain("c > d"); - }); - - it("should block HTTP URLs while preserving HTTPS URLs", () => { - const input = "HTTP: http://bad.com and HTTPS: https://github.com"; - const result = sanitizeContentFunction(input); - expect(result).toContain("(redacted)"); // HTTP URL blocked - expect(result).toContain("https://github.com"); // HTTPS URL preserved - expect(result).not.toContain("http://bad.com"); - }); - - it("should block various unsafe protocols", () => { - const input = "Bad: ftp://file.com javascript:alert(1) file://local data:text/html, - -Special chars: \x00\x1F & "quotes" 'apostrophes' - `.trim(); - - const result = sanitizeContentFunction(input); - - // Check @mention neutralization - expect(result).toContain("`@user`"); - - // Check bot trigger neutralization - expect(result).toContain("`fixes #123`"); - - // Check URL filtering - expect(result).toContain("(redacted)"); // HTTP and JavaScript URLs - expect(result).toContain("https://github.com/repo"); - expect(result).not.toContain("http://bad.com"); - expect(result).not.toContain("javascript:alert"); - - // Check XML tag conversion - expect(result).toContain("(script)"); - expect(result).toContain('"quotes"'); - expect(result).toContain("'apostrophes'"); - expect(result).toContain("&"); - - // Check control character removal - expect(result).not.toContain("\x00"); - expect(result).not.toContain("\x1F"); - }); - - it("should trim excessive whitespace", () => { - const input = " \n\n Content with spacing \n\n "; - const result = sanitizeContentFunction(input); - expect(result).toBe("Content with spacing"); - }); - - it("should handle empty environment variable gracefully", () => { - // Clear GitHub environment variables to test empty domains behavior - const originalServerUrl = process.env.GITHUB_SERVER_URL; - const originalApiUrl = process.env.GITHUB_API_URL; - delete process.env.GITHUB_SERVER_URL; - delete process.env.GITHUB_API_URL; - - process.env.GH_AW_ALLOWED_DOMAINS = " , , "; - - const scriptWithExport = sanitizeScript.replace("await main();", "global.testSanitizeContent = sanitizeContent;"); - eval(scriptWithExport); - const customSanitize = global.testSanitizeContent; - - const input = "Link: https://github.com/repo"; - const result = customSanitize(input); - // With empty allowedDomains array, all HTTPS URLs get blocked - expect(result).toContain("(redacted)"); - expect(result).not.toContain("https://github.com/repo"); - - // Restore GitHub environment variables - if (originalServerUrl) process.env.GITHUB_SERVER_URL = originalServerUrl; - if (originalApiUrl) process.env.GITHUB_API_URL = originalApiUrl; - }); - - it("should handle @mentions with various formats", () => { - const input = "Contact @user123, @org-name/team_name, @a, and @normalname"; - const result = sanitizeContentFunction(input); - expect(result).toContain("`@user123`"); - expect(result).toContain("`@org-name/team_name`"); - expect(result).toContain("`@a`"); - expect(result).toContain("`@normalname`"); - }); - - it("should not neutralize @mentions at start of backticked expressions", () => { - const input = "Code: `@user.method()` and normal @user mention"; - const result = sanitizeContentFunction(input); - expect(result).toContain("`@user.method()`"); // Should remain unchanged - expect(result).toContain("`@user`"); // Should be neutralized - }); - - it("should handle various bot trigger phrase formats", () => { - const input = "Fix #123, close #abc, FIXES #XYZ, resolves #456, fixes #789"; - const result = sanitizeContentFunction(input); - expect(result).toContain("`Fix #123`"); - expect(result).toContain("`close #abc`"); - expect(result).toContain("`FIXES #XYZ`"); - expect(result).toContain("`resolves #456`"); // With space - expect(result).toContain("`fixes #789`"); // Multiple spaces normalized to single - }); - - it("should handle edge cases in protocol filtering", () => { - const input = ` - Protocols: HTTP://CAPS.COM, https://github.com/path?query=value#fragment - More: mailto:user@domain.com tel:+1234567890 ssh://server:22/path - Edge: ://malformed http:// https:// - Nested: (https://github.com) [http://bad.com] "ftp://files.com" - `; - const result = sanitizeContentFunction(input); - - // Check case insensitive protocol blocking - expect(result).toContain("(redacted)"); // HTTP://CAPS.COM - expect(result).toContain("https://github.com/path?query=value#fragment"); - expect(result).toContain("(redacted)"); // mailto, tel, ssh, http, ftp - expect(result).not.toContain("HTTP://CAPS.COM"); - expect(result).not.toContain("mailto:user@domain.com"); - expect(result).not.toContain("tel:+1234567890"); - expect(result).not.toContain("ssh://server:22/path"); - }); - - it("should preserve HTTPS URLs in various contexts", () => { - const input = ` - Links in text: Visit https://github.com/user/repo for details. - In parentheses: (https://github.io/docs) - In brackets: [https://githubusercontent.com/file.txt] - Multiple: https://github.com https://github.io https://githubassets.com - `; - const result = sanitizeContentFunction(input); - - expect(result).toContain("https://github.com/user/repo"); - expect(result).toContain("https://github.io/docs"); - expect(result).toContain("https://githubusercontent.com/file.txt"); - expect(result).toContain("https://github.com"); - expect(result).toContain("https://github.io"); - expect(result).toContain("https://githubassets.com"); - }); - - it("should handle complex domain matching scenarios", () => { - const input = ` - Valid: https://api.github.com/v4/graphql https://docs.github.com/en/ - Invalid: https://github.com.evil.com https://notgithub.com - Edge: https://github.com.attacker.com https://sub.github.io.fake.com - `; - const result = sanitizeContentFunction(input); - - // Valid subdomains should be preserved - expect(result).toContain("https://api.github.com/v4/graphql"); - expect(result).toContain("https://docs.github.com/en/"); - - // Invalid domains should be blocked - expect(result).toContain("(redacted)"); - expect(result).not.toContain("github.com.evil.com"); - expect(result).not.toContain("notgithub.com"); - expect(result).not.toContain("github.com.attacker.com"); - expect(result).not.toContain("sub.github.io.fake.com"); - }); - - it("should handle URLs with special characters and edge cases", () => { - const input = ` - URLs: https://github.com/user/repo-name_with.dots - Query: https://github.com/search?q=test&type=code - Fragment: https://github.com/user/repo#readme - Port: https://github.dev:443/workspace - Auth: https://github.com/repo (user info stripped by domain parsing) - `; - const result = sanitizeContentFunction(input); - - expect(result).toContain("https://github.com/user/repo-name_with.dots"); - expect(result).toContain("https://github.com/search?q=test&type=code"); // & not escaped - expect(result).toContain("https://github.com/user/repo#readme"); - expect(result).toContain("https://github.dev:443/workspace"); - expect(result).toContain("https://github.com/repo"); - }); - - it("should handle length truncation at exact boundary", () => { - const exactLength = 524288; - const input = "x".repeat(exactLength); - const result = sanitizeContentFunction(input); - expect(result.length).toBe(exactLength); - expect(result).not.toContain("[Content truncated due to length]"); - - const overLength = "x".repeat(exactLength + 100); // Significantly longer - const overResult = sanitizeContentFunction(overLength); - // The result should be truncated and contain the truncation message - expect(overResult).toContain("[Content truncated due to length]"); - // The result should be shorter than the original due to truncation - expect(overResult.length).toBeLessThan(overLength.length); - }); - - it("should handle line truncation at exact boundary", () => { - const exactLines = 65000; - // Create content with exactly 65000 lines (65000 newlines = 65001 elements when split) - const input = Array(exactLines).fill("line").join("\n"); - const result = sanitizeContentFunction(input); - const lines = result.split("\n"); - expect(lines.length).toBe(exactLines); - expect(result).not.toContain("[Content truncated due to line count]"); - - // Test with more than 65000 lines - const overLines = Array(exactLines + 1) - .fill("line") - .join("\n"); - const overResult = sanitizeContentFunction(overLines); - const overResultLines = overResult.split("\n"); - expect(overResultLines.length).toBeLessThanOrEqual(exactLines + 1); // +1 for truncation message - expect(overResult).toContain("[Content truncated due to line count]"); - }); - - it("should handle various ANSI escape sequence patterns", () => { - const input = ` - Color: \x1b[31mRed\x1b[0m \x1b[1;32mBold Green\x1b[m - Cursor: \x1b[2J\x1b[H Clear and home - Other: \x1b[?25h Show cursor \x1b[K Clear line - Complex: \x1b[38;5;196mTrueColor\x1b[0m - `; - const result = sanitizeContentFunction(input); - - expect(result).not.toContain("\x1b["); - expect(result).toContain("Red"); - expect(result).toContain("Bold Green"); - expect(result).toContain("Clear and home"); - expect(result).toContain("Show cursor"); - expect(result).toContain("Clear line"); - expect(result).toContain("TrueColor"); - }); - - it("should handle XML tag conversion in complex nested content", () => { - const input = ` - - alert("xss")]]> - - - `; - const result = sanitizeContentFunction(input); - - expect(result).toContain("(xml attr=\"value & 'quotes'\")"); - expect(result).toContain('(![CDATA[(script)alert("xss")(/script)]])'); - // XML comments are removed for security (to prevent content hiding) - expect(result).not.toContain("comment with"); - expect(result).toContain("(/xml)"); - }); - - it("should handle non-string inputs robustly", () => { - expect(sanitizeContentFunction(123)).toBe(""); - expect(sanitizeContentFunction({})).toBe(""); - expect(sanitizeContentFunction([])).toBe(""); - expect(sanitizeContentFunction(true)).toBe(""); - expect(sanitizeContentFunction(false)).toBe(""); - }); - - it("should preserve line breaks and tabs in content structure", () => { - const input = `Line 1 -\t\tIndented line -\n\nDouble newline - -\tTab at start`; - const result = sanitizeContentFunction(input); - - expect(result).toContain("\n"); - expect(result).toContain("\t"); - expect(result.split("\n").length).toBeGreaterThan(1); - expect(result).toContain("Line 1"); - expect(result).toContain("Indented line"); - expect(result).toContain("Tab at start"); - }); - - it("should handle simultaneous protocol and domain filtering", () => { - const input = ` - Good HTTPS: https://github.com/repo - Bad HTTPS: https://evil.com/malware - Bad HTTP allowed domain: http://github.com/repo - Mixed: https://evil.com/path?goto=https://github.com/safe - `; - const result = sanitizeContentFunction(input); - - expect(result).toContain("https://github.com/repo"); - expect(result).toContain("(redacted)"); // For evil.com and http://github.com - expect(result).not.toContain("https://evil.com"); - expect(result).not.toContain("http://github.com"); - - // The safe URL in query param should still be preserved - expect(result).toContain("https://github.com/safe"); - }); - }); - - describe("main function", () => { - beforeEach(() => { - // Clean up any test files - const testFile = "/tmp/gh-aw/test-output.txt"; - if (fs.existsSync(testFile)) { - fs.unlinkSync(testFile); - } - - // Make fs available globally for the evaluated script - global.fs = fs; - }); - - afterEach(() => { - // Clean up global fs - delete global.fs; - }); - - it("should handle missing GH_AW_SAFE_OUTPUTS environment variable", async () => { - delete process.env.GH_AW_SAFE_OUTPUTS; - - // Execute the script - await eval(`(async () => { ${sanitizeScript} })()`); - - expect(mockCore.info).toHaveBeenCalledWith("GH_AW_SAFE_OUTPUTS not set, no output to collect"); - expect(mockCore.setOutput).toHaveBeenCalledWith("output", ""); - }); - - it("should handle non-existent output file", async () => { - process.env.GH_AW_SAFE_OUTPUTS = "/tmp/gh-aw/non-existent-file.txt"; - - // Execute the script - await eval(`(async () => { ${sanitizeScript} })()`); - - expect(mockCore.info).toHaveBeenCalledWith(`Output file does not exist: ${"/tmp/gh-aw/non-existent-file.txt"}`); - expect(mockCore.setOutput).toHaveBeenCalledWith("output", ""); - }); - - it("should handle empty output file", async () => { - const testFile = "/tmp/gh-aw/test-empty-output.txt"; - fs.writeFileSync(testFile, " \n \t \n "); - process.env.GH_AW_SAFE_OUTPUTS = testFile; - - // Execute the script - await eval(`(async () => { ${sanitizeScript} })()`); - - expect(mockCore.info).toHaveBeenCalledWith("Output file is empty"); - expect(mockCore.setOutput).toHaveBeenCalledWith("output", ""); - - fs.unlinkSync(testFile); - }); - - it("should process and sanitize output file content", async () => { - const testContent = "Hello @user! This fixes #123. Link: http://bad.com and https://github.com/repo"; - const testFile = "/tmp/gh-aw/test-output.txt"; - fs.writeFileSync(testFile, testContent); - process.env.GH_AW_SAFE_OUTPUTS = testFile; - - // Execute the script - await eval(`(async () => { ${sanitizeScript} })()`); - - expect(mockCore.info).toHaveBeenCalledWith(expect.stringMatching(/Collected agentic output \(sanitized\):.*@user/)); - - const outputCall = mockCore.setOutput.mock.calls.find(call => call[0] === "output"); - expect(outputCall).toBeDefined(); - const sanitizedOutput = outputCall[1]; - - // Verify sanitization occurred - expect(sanitizedOutput).toContain("`@user`"); - expect(sanitizedOutput).toContain("`fixes #123`"); - expect(sanitizedOutput).toContain("(redacted)"); // HTTP URL - expect(sanitizedOutput).toContain("https://github.com/repo"); // HTTPS URL preserved - - fs.unlinkSync(testFile); - }); - - it("should truncate log output for very long content", async () => { - const longContent = "x".repeat(250); // More than 200 chars to trigger truncation - const testFile = "/tmp/gh-aw/test-long-output.txt"; - fs.writeFileSync(testFile, longContent); - 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(); - expect(outputLogCall[0]).toContain("..."); - expect(outputLogCall[0].length).toBeLessThan(longContent.length); - - fs.unlinkSync(testFile); - }); - - it("should handle file read errors gracefully", async () => { - // Create a file and then remove read permissions - const testFile = "/tmp/gh-aw/test-no-read.txt"; - fs.writeFileSync(testFile, "test content"); - - // Mock readFileSync to throw an error - const originalReadFileSync = fs.readFileSync; - const readFileSyncSpy = vi.spyOn(fs, "readFileSync").mockImplementation(() => { - throw new Error("Permission denied"); - }); - - process.env.GH_AW_SAFE_OUTPUTS = testFile; - - let thrownError = null; - try { - // Execute the script - it should throw but we catch it - await eval(`(async () => { ${sanitizeScript} })()`); - } catch (error) { - thrownError = error; - } - - expect(thrownError).toBeTruthy(); - expect(thrownError.message).toContain("Permission denied"); - - // Restore spies - readFileSyncSpy.mockRestore(); - // Clean up - if (fs.existsSync(testFile)) { - fs.unlinkSync(testFile); - } - }); - - it("should handle binary file content", async () => { - const binaryData = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe, 0xfd]); - const testFile = "/tmp/gh-aw/test-binary.txt"; - fs.writeFileSync(testFile, binaryData); - process.env.GH_AW_SAFE_OUTPUTS = testFile; - - // Execute the script - await eval(`(async () => { ${sanitizeScript} })()`); - - // Should handle binary data gracefully - const outputCall = mockCore.setOutput.mock.calls.find(call => call[0] === "output"); - expect(outputCall).toBeDefined(); - - fs.unlinkSync(testFile); - }); - - it("should handle content with only whitespace", async () => { - const whitespaceContent = " \n\n\t\t \r\n "; - const testFile = "/tmp/gh-aw/test-whitespace.txt"; - fs.writeFileSync(testFile, whitespaceContent); - process.env.GH_AW_SAFE_OUTPUTS = testFile; - - // Execute the script - await eval(`(async () => { ${sanitizeScript} })()`); - - expect(mockCore.info).toHaveBeenCalledWith("Output file is empty"); - expect(mockCore.setOutput).toHaveBeenCalledWith("output", ""); - - fs.unlinkSync(testFile); - }); - - it("should handle very large files with mixed content", async () => { - // Create content that will trigger both length and line truncation - const lineContent = 'This is a line with @user and https://evil.com plus \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); - }); - }); -}); + (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`"), // Already in backticks, stays as is + 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('