From 1d6d5298d30033a6116d744100be8fafe71de574 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 18 Dec 2025 06:52:48 +0000
Subject: [PATCH 1/5] Initial plan
From b826ee6a9fef4086e04979b783e624723f517558 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 18 Dec 2025 06:57:26 +0000
Subject: [PATCH 2/5] Initial exploration of JavaScript formatting
infrastructure
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
...ze-reduction-project64.campaign.g.lock.yml | 571 +++---------------
1 file changed, 82 insertions(+), 489 deletions(-)
diff --git a/.github/workflows/go-file-size-reduction-project64.campaign.g.lock.yml b/.github/workflows/go-file-size-reduction-project64.campaign.g.lock.yml
index bef2ab45c61..36ab6dfa37e 100644
--- a/.github/workflows/go-file-size-reduction-project64.campaign.g.lock.yml
+++ b/.github/workflows/go-file-size-reduction-project64.campaign.g.lock.yml
@@ -19,82 +19,7 @@
# gh aw compile
# For more information: https://github.com/githubnext/gh-aw/blob/main/.github/aw/github-agentic-workflows.md
#
-#
# Systematically reduce oversized Go files to improve maintainability. Success: all files ≤800 LOC, maintain coverage, no regressions.
-#
-# Original Frontmatter:
-# ```yaml
-# name: "Go File Size Reduction Campaign (Project 64)"
-# description: "Systematically reduce oversized Go files to improve maintainability. Success: all files ≤800 LOC, maintain coverage, no regressions."
-# on:
-# schedule:
-# - cron: "0 18 * * *"
-# workflow_dispatch:
-# engine: copilot
-# safe-outputs:
-# add-comment:
-# max: 10
-# update-project:
-# max: 10
-# runs-on: ubuntu-latest
-# roles:
-# - "admin"
-# - "maintainer"
-# - "write"
-# ```
-#
-# Job Dependency Graph:
-# ```mermaid
-# graph LR
-# activation["activation"]
-# add_comment["add_comment"]
-# agent["agent"]
-# conclusion["conclusion"]
-# detection["detection"]
-# update_project["update_project"]
-# activation --> agent
-# activation --> conclusion
-# add_comment --> conclusion
-# agent --> add_comment
-# agent --> conclusion
-# agent --> detection
-# agent --> update_project
-# detection --> add_comment
-# detection --> conclusion
-# detection --> update_project
-# update_project --> conclusion
-# ```
-#
-# Original Prompt:
-# ```markdown
-# # Campaign Orchestrator
-#
-# This workflow orchestrates the 'Go File Size Reduction Campaign (Project 64)' campaign.
-#
-# - Tracker label: `campaign:go-file-size-reduction-project64`
-# - Associated workflows: daily-file-diet
-# - Memory paths: memory/campaigns/go-file-size-reduction-project64-*/**
-# - Metrics glob: `memory/campaigns/go-file-size-reduction-project64-*/metrics/*.json`
-# - Project URL: https://github.com/orgs/githubnext/projects/64
-#
-# Each time this orchestrator runs on its daily schedule (or when manually dispatched), generate a concise status report for this campaign. Summarize current metrics and update any tracker issues using the campaign label.
-#
-# If all issues with the campaign label are closed, the campaign is complete. This is a normal terminal state indicating successful completion, not a blocker or error. When the campaign is complete, mark the project as finished and take no further action. Do not report closed issues as blockers.
-#
-# Keep the campaign Project dashboard in sync using the `update-project` safe output. When calling update-project, use the `project` field with this exact URL: https://github.com/orgs/githubnext/projects/64
-#
-# Use these details to coordinate workers, update metrics, and track progress for this campaign.
-# ```
-#
-# Pinned GitHub Actions:
-# - actions/checkout@v5 (93cb6efe18208431cddfb8368fd83d5badbf9bfd)
-# https://github.com/actions/checkout/commit/93cb6efe18208431cddfb8368fd83d5badbf9bfd
-# - actions/download-artifact@v6 (018cc2cf5baa6db3ef3c5f8a56943fffe632ef53)
-# https://github.com/actions/download-artifact/commit/018cc2cf5baa6db3ef3c5f8a56943fffe632ef53
-# - actions/github-script@v8 (ed597411d8f924073f98dfc5c65a23a2325f34cd)
-# https://github.com/actions/github-script/commit/ed597411d8f924073f98dfc5c65a23a2325f34cd
-# - actions/upload-artifact@v5 (330a01c490aca151604b8cf639adc76d48f6c5d4)
-# https://github.com/actions/upload-artifact/commit/330a01c490aca151604b8cf639adc76d48f6c5d4
name: "Go File Size Reduction Campaign (Project 64)"
"on":
@@ -188,9 +113,7 @@ jobs:
.addRaw("**Files:**\n")
.addRaw(`- Source: \`${workflowMdPath}\`\n`)
.addRaw(` - Last commit: ${workflowTimestamp}\n`)
- .addRaw(
- ` - Commit SHA: [\`${workflowCommit.sha.substring(0, 7)}\`](https://github.com/${owner}/${repo}/commit/${workflowCommit.sha})\n`
- )
+ .addRaw(` - Commit SHA: [\`${workflowCommit.sha.substring(0, 7)}\`](https://github.com/${owner}/${repo}/commit/${workflowCommit.sha})\n`)
.addRaw(`- Lock: \`${lockFilePath}\`\n`)
.addRaw(` - Last commit: ${lockTimestamp}\n`)
.addRaw(` - Commit SHA: [\`${lockCommit.sha.substring(0, 7)}\`](https://github.com/${owner}/${repo}/commit/${lockCommit.sha})\n\n`)
@@ -340,11 +263,8 @@ jobs:
}
const messages = getMessages();
const templateContext = toSnakeCase(ctx);
- const defaultInstall =
- "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!";
- return messages?.footerInstall
- ? renderTemplate(messages.footerInstall, templateContext)
- : renderTemplate(defaultInstall, templateContext);
+ const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!";
+ return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext);
}
function generateXMLMarker(workflowName, runUrl) {
const engineId = process.env.GH_AW_ENGINE_ID || "";
@@ -368,15 +288,7 @@ jobs:
parts.push(`run: ${runUrl}`);
return ``;
}
- function generateFooterWithMessages(
- workflowName,
- runUrl,
- workflowSource,
- workflowSourceURL,
- triggeringIssueNumber,
- triggeringPRNumber,
- triggeringDiscussionNumber
- ) {
+ function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) {
let triggeringNumber;
if (triggeringIssueNumber) {
triggeringNumber = triggeringIssueNumber;
@@ -723,10 +635,7 @@ jobs:
const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering";
core.info(`Comment target configuration: ${commentTarget}`);
const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment";
- const isPRContext =
- context.eventName === "pull_request" ||
- context.eventName === "pull_request_review" ||
- context.eventName === "pull_request_review_comment";
+ const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment";
const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment";
const isDiscussion = isDiscussionContext || isDiscussionExplicit;
const workflowId = process.env.GITHUB_WORKFLOW || "";
@@ -795,10 +704,8 @@ jobs:
core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation');
return;
}
- const triggeringIssueNumber =
- context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined;
- const triggeringPRNumber =
- context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined);
+ const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined;
+ const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined);
const triggeringDiscussionNumber = context.payload?.discussion?.number;
const createdComments = [];
for (let i = 0; i < commentItems.length; i++) {
@@ -886,9 +793,7 @@ jobs:
const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || "";
const runId = context.runId;
const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository
- ? `${context.payload.repository.html_url}/actions/runs/${runId}`
- : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
+ const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
if (workflowId) {
body += `\n\n`;
}
@@ -897,28 +802,11 @@ jobs:
body += trackerIDComment;
}
body += `\n\n`;
- body += generateFooterWithMessages(
- workflowName,
- runUrl,
- workflowSource,
- workflowSourceURL,
- triggeringIssueNumber,
- triggeringPRNumber,
- triggeringDiscussionNumber
- );
+ body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber);
try {
if (hideOlderCommentsEnabled && workflowId) {
core.info("Hide-older-comments is enabled, searching for previous comments to hide");
- await hideOlderComments(
- github,
- context.repo.owner,
- context.repo.repo,
- itemNumber,
- workflowId,
- commentEndpoint === "discussions",
- "outdated",
- allowedReasons
- );
+ await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons);
}
let comment;
if (commentEndpoint === "discussions") {
@@ -1484,9 +1372,7 @@ jobs:
server.debug(` [${toolName}] Python handler args: ${JSON.stringify(args)}`);
server.debug(` [${toolName}] Timeout: ${timeoutSeconds}s`);
const inputJson = JSON.stringify(args || {});
- server.debug(
- ` [${toolName}] Input JSON (${inputJson.length} bytes): ${inputJson.substring(0, 200)}${inputJson.length > 200 ? "..." : ""}`
- );
+ server.debug(` [${toolName}] Input JSON (${inputJson.length} bytes): ${inputJson.substring(0, 200)}${inputJson.length > 200 ? "..." : ""}`);
return new Promise((resolve, reject) => {
server.debug(` [${toolName}] Executing Python script...`);
const child = execFile(
@@ -1594,9 +1480,7 @@ jobs:
try {
if (fs.existsSync(outputFile)) {
const outputContent = fs.readFileSync(outputFile, "utf-8");
- server.debug(
- ` [${toolName}] Output file content: ${outputContent.substring(0, 500)}${outputContent.length > 500 ? "..." : ""}`
- );
+ server.debug(` [${toolName}] Output file content: ${outputContent.substring(0, 500)}${outputContent.length > 500 ? "..." : ""}`);
const lines = outputContent.split("\n");
for (const line of lines) {
const trimmed = line.trim();
@@ -1654,10 +1538,7 @@ jobs:
fs.mkdirSync(server.logDir, { recursive: true });
}
const timestamp = new Date().toISOString();
- fs.writeFileSync(
- server.logFilePath,
- `# ${server.serverInfo.name} MCP Server Log\n# Started: ${timestamp}\n# Version: ${server.serverInfo.version}\n\n`
- );
+ fs.writeFileSync(server.logFilePath, `# ${server.serverInfo.name} MCP Server Log\n# Started: ${timestamp}\n# Version: ${server.serverInfo.version}\n\n`);
server.logFileInitialized = true;
} catch {
}
@@ -2317,10 +2198,7 @@ jobs:
const isInWorkspace = absolutePath.startsWith(path.resolve(workspaceDir));
const isInTmp = absolutePath.startsWith(tmpDir);
if (!isInWorkspace && !isInTmp) {
- throw new Error(
- `File path must be within workspace directory (${workspaceDir}) or /tmp directory. ` +
- `Provided path: ${filePath} (resolved to: ${absolutePath})`
- );
+ throw new Error(`File path must be within workspace directory (${workspaceDir}) or /tmp directory. ` + `Provided path: ${filePath} (resolved to: ${absolutePath})`);
}
if (!fs.existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`);
@@ -2579,10 +2457,7 @@ jobs:
};
const entryJSON = JSON.stringify(entry);
fs.appendFileSync(outputFile, entryJSON + "\n");
- const outputText =
- jobConfig && jobConfig.output
- ? jobConfig.output
- : `Safe-job '${configKey}' executed successfully with arguments: ${JSON.stringify(args)}`;
+ const outputText = jobConfig && jobConfig.output ? jobConfig.output : `Safe-job '${configKey}' executed successfully with arguments: ${JSON.stringify(args)}`;
return {
content: [
{
@@ -3098,16 +2973,13 @@ jobs:
return result;
}
function renderMarkdownTemplate(markdown) {
- let result = markdown.replace(
- /(\n?)([ \t]*{{#if\s+([^}]*)}}[ \t]*\n)([\s\S]*?)([ \t]*{{\/if}}[ \t]*)(\n?)/g,
- (match, leadNL, openLine, cond, body, closeLine, trailNL) => {
- if (isTruthy(cond)) {
- return leadNL + body;
- } else {
- return "";
- }
+ let result = markdown.replace(/(\n?)([ \t]*{{#if\s+([^}]*)}}[ \t]*\n)([\s\S]*?)([ \t]*{{\/if}}[ \t]*)(\n?)/g, (match, leadNL, openLine, cond, body, closeLine, trailNL) => {
+ if (isTruthy(cond)) {
+ return leadNL + body;
+ } else {
+ return "";
}
- );
+ });
result = result.replace(/{{#if\s+([^}]*)}}([\s\S]*?){{\/if}}/g, (_, cond, body) => (isTruthy(cond) ? body : ""));
result = result.replace(/\n{3,}/g, "\n\n");
return result;
@@ -3477,36 +3349,33 @@ jobs:
});
}
function sanitizeUrlProtocols(s) {
- return s.replace(
- /((?:http|ftp|file|ssh|git):\/\/([\w.-]*)(?:[^\s]*)|(?:data|javascript|vbscript|about|mailto|tel):[^\s]+)/gi,
- (match, _fullMatch, domain) => {
- if (domain) {
- const domainLower = domain.toLowerCase();
- const truncated = domainLower.length > 12 ? domainLower.substring(0, 12) + "..." : domainLower;
+ return s.replace(/((?:http|ftp|file|ssh|git):\/\/([\w.-]*)(?:[^\s]*)|(?:data|javascript|vbscript|about|mailto|tel):[^\s]+)/gi, (match, _fullMatch, domain) => {
+ if (domain) {
+ const domainLower = domain.toLowerCase();
+ const truncated = domainLower.length > 12 ? domainLower.substring(0, 12) + "..." : domainLower;
+ if (typeof core !== "undefined" && core.info) {
+ core.info(`Redacted URL: ${truncated}`);
+ }
+ if (typeof core !== "undefined" && core.debug) {
+ core.debug(`Redacted URL (full): ${match}`);
+ }
+ addRedactedDomain(domainLower);
+ } else {
+ const protocolMatch = match.match(/^([^:]+):/);
+ if (protocolMatch) {
+ const protocol = protocolMatch[1] + ":";
+ const truncated = match.length > 12 ? match.substring(0, 12) + "..." : match;
if (typeof core !== "undefined" && core.info) {
core.info(`Redacted URL: ${truncated}`);
}
if (typeof core !== "undefined" && core.debug) {
core.debug(`Redacted URL (full): ${match}`);
}
- addRedactedDomain(domainLower);
- } else {
- const protocolMatch = match.match(/^([^:]+):/);
- if (protocolMatch) {
- const protocol = protocolMatch[1] + ":";
- const truncated = match.length > 12 ? match.substring(0, 12) + "..." : match;
- if (typeof core !== "undefined" && core.info) {
- core.info(`Redacted URL: ${truncated}`);
- }
- if (typeof core !== "undefined" && core.debug) {
- core.debug(`Redacted URL (full): ${match}`);
- }
- addRedactedDomain(protocol);
- }
+ addRedactedDomain(protocol);
}
- return "(redacted)";
}
- );
+ return "(redacted)";
+ });
}
function neutralizeCommands(s) {
const commandName = process.env.GH_AW_COMMAND;
@@ -3528,37 +3397,7 @@ jobs:
return s.replace(//g, "").replace(//g, "");
}
function convertXmlTags(s) {
- const allowedTags = [
- "b",
- "blockquote",
- "br",
- "code",
- "details",
- "em",
- "h1",
- "h2",
- "h3",
- "h4",
- "h5",
- "h6",
- "hr",
- "i",
- "li",
- "ol",
- "p",
- "pre",
- "strong",
- "sub",
- "summary",
- "sup",
- "table",
- "tbody",
- "td",
- "th",
- "thead",
- "tr",
- "ul",
- ];
+ const allowedTags = ["b", "blockquote", "br", "code", "details", "em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "li", "ol", "p", "pre", "strong", "sub", "summary", "sup", "table", "tbody", "td", "th", "thead", "tr", "ul"];
s = s.replace(//g, (match, content) => {
const convertedContent = content.replace(/<(\/?[A-Za-z][A-Za-z0-9]*(?:[^>]*?))>/g, "($1)");
return `(![CDATA[${convertedContent}]])`;
@@ -3670,36 +3509,33 @@ jobs:
return result;
}
function sanitizeUrlProtocols(s) {
- return s.replace(
- /\b((?:http|ftp|file|ssh|git):\/\/([\w.-]+)(?:[^\s]*)|(?:data|javascript|vbscript|about|mailto|tel):[^\s]+)/gi,
- (match, _fullMatch, domain) => {
- if (domain) {
- const domainLower = domain.toLowerCase();
- const truncated = domainLower.length > 12 ? domainLower.substring(0, 12) + "..." : domainLower;
+ return s.replace(/\b((?:http|ftp|file|ssh|git):\/\/([\w.-]+)(?:[^\s]*)|(?:data|javascript|vbscript|about|mailto|tel):[^\s]+)/gi, (match, _fullMatch, domain) => {
+ if (domain) {
+ const domainLower = domain.toLowerCase();
+ const truncated = domainLower.length > 12 ? domainLower.substring(0, 12) + "..." : domainLower;
+ if (typeof core !== "undefined" && core.info) {
+ core.info(`Redacted URL: ${truncated}`);
+ }
+ if (typeof core !== "undefined" && core.debug) {
+ core.debug(`Redacted URL (full): ${match}`);
+ }
+ addRedactedDomain(domainLower);
+ } else {
+ const protocolMatch = match.match(/^([^:]+):/);
+ if (protocolMatch) {
+ const protocol = protocolMatch[1] + ":";
+ const truncated = match.length > 12 ? match.substring(0, 12) + "..." : match;
if (typeof core !== "undefined" && core.info) {
core.info(`Redacted URL: ${truncated}`);
}
if (typeof core !== "undefined" && core.debug) {
core.debug(`Redacted URL (full): ${match}`);
}
- addRedactedDomain(domainLower);
- } else {
- const protocolMatch = match.match(/^([^:]+):/);
- if (protocolMatch) {
- const protocol = protocolMatch[1] + ":";
- const truncated = match.length > 12 ? match.substring(0, 12) + "..." : match;
- if (typeof core !== "undefined" && core.info) {
- core.info(`Redacted URL: ${truncated}`);
- }
- if (typeof core !== "undefined" && core.debug) {
- core.debug(`Redacted URL (full): ${match}`);
- }
- addRedactedDomain(protocol);
- }
+ addRedactedDomain(protocol);
}
- return "(redacted)";
}
- );
+ return "(redacted)";
+ });
}
function neutralizeCommands(s) {
const commandName = process.env.GH_AW_COMMAND;
@@ -3725,37 +3561,7 @@ jobs:
return s.replace(//g, "").replace(//g, "");
}
function convertXmlTags(s) {
- const allowedTags = [
- "b",
- "blockquote",
- "br",
- "code",
- "details",
- "em",
- "h1",
- "h2",
- "h3",
- "h4",
- "h5",
- "h6",
- "hr",
- "i",
- "li",
- "ol",
- "p",
- "pre",
- "strong",
- "sub",
- "summary",
- "sup",
- "table",
- "tbody",
- "td",
- "th",
- "thead",
- "tr",
- "ul",
- ];
+ const allowedTags = ["b", "blockquote", "br", "code", "details", "em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "li", "ol", "p", "pre", "strong", "sub", "summary", "sup", "table", "tbody", "td", "th", "thead", "tr", "ul"];
s = s.replace(//g, (match, content) => {
const convertedContent = content.replace(/<(\/?[A-Za-z][A-Za-z0-9]*(?:[^>]*?))>/g, "($1)");
return `(![CDATA[${convertedContent}]])`;
@@ -4447,9 +4253,7 @@ jobs:
core.info(`Loaded validation config from ${validationConfigPath}`);
}
} catch (error) {
- core.warning(
- `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}`
- );
+ core.warning(`Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}`);
}
const mentionsConfig = validationConfig?.mentions || null;
const allowedMentions = await resolveAllowedMentionsFromPayload(context, github, core, mentionsConfig);
@@ -4662,9 +4466,7 @@ jobs:
core.info(`[INGESTION] Line ${i + 1}: Original type='${originalType}', Normalized type='${itemType}'`);
item.type = itemType;
if (!expectedOutputTypes[itemType]) {
- core.warning(
- `[INGESTION] Line ${i + 1}: Type '${itemType}' not found in expected types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`
- );
+ core.warning(`[INGESTION] Line ${i + 1}: Type '${itemType}' not found in expected types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`);
errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`);
continue;
}
@@ -4746,10 +4548,7 @@ jobs:
core.info(`Patch file ${hasPatch ? "exists" : "does not exist"} at: ${patchPath}`);
let allowEmptyPR = false;
if (safeOutputsConfig) {
- if (
- safeOutputsConfig["create-pull-request"]?.["allow-empty"] === true ||
- safeOutputsConfig["create_pull_request"]?.["allow_empty"] === true
- ) {
+ if (safeOutputsConfig["create-pull-request"]?.["allow-empty"] === true || safeOutputsConfig["create_pull_request"]?.["allow_empty"] === true) {
allowEmptyPR = true;
core.info(`allow-empty is enabled for create-pull-request`);
}
@@ -5120,24 +4919,7 @@ jobs:
"Custom Agents": [],
Other: [],
};
- const builtinTools = [
- "bash",
- "write_bash",
- "read_bash",
- "stop_bash",
- "list_bash",
- "grep",
- "glob",
- "view",
- "create",
- "edit",
- "store_memory",
- "code_review",
- "codeql_checker",
- "report_progress",
- "report_intent",
- "gh-advisory-database",
- ];
+ const builtinTools = ["bash", "write_bash", "read_bash", "stop_bash", "list_bash", "grep", "glob", "view", "create", "edit", "store_memory", "code_review", "codeql_checker", "report_progress", "report_intent", "gh-advisory-database"];
const internalTools = ["fetch_copilot_cli_documentation"];
for (const tool of initEntry.tools) {
const toolLower = tool.toLowerCase();
@@ -5533,9 +5315,7 @@ jobs:
const cacheCreationTokens = usage.cache_creation_input_tokens || 0;
const cacheReadTokens = usage.cache_read_input_tokens || 0;
const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens;
- lines.push(
- ` Tokens: ${totalTokens.toLocaleString()} total (${usage.input_tokens.toLocaleString()} in / ${usage.output_tokens.toLocaleString()} out)`
- );
+ lines.push(` Tokens: ${totalTokens.toLocaleString()} total (${usage.input_tokens.toLocaleString()} in / ${usage.output_tokens.toLocaleString()} out)`);
}
}
if (lastEntry?.total_cost_usd) {
@@ -5694,9 +5474,7 @@ jobs:
const cacheCreationTokens = usage.cache_creation_input_tokens || 0;
const cacheReadTokens = usage.cache_read_input_tokens || 0;
const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens;
- lines.push(
- ` Tokens: ${totalTokens.toLocaleString()} total (${usage.input_tokens.toLocaleString()} in / ${usage.output_tokens.toLocaleString()} out)`
- );
+ lines.push(` Tokens: ${totalTokens.toLocaleString()} total (${usage.input_tokens.toLocaleString()} in / ${usage.output_tokens.toLocaleString()} out)`);
}
}
if (lastEntry?.total_cost_usd) {
@@ -5797,11 +5575,7 @@ jobs:
});
}
function extractPremiumRequestCount(logContent) {
- const patterns = [
- /premium\s+requests?\s+consumed:?\s*(\d+)/i,
- /(\d+)\s+premium\s+requests?\s+consumed/i,
- /consumed\s+(\d+)\s+premium\s+requests?/i,
- ];
+ const patterns = [/premium\s+requests?\s+consumed:?\s*(\d+)/i, /(\d+)\s+premium\s+requests?\s+consumed/i, /consumed\s+(\d+)\s+premium\s+requests?/i];
for (const pattern of patterns) {
const match = logContent.match(pattern);
if (match && match[1]) {
@@ -5873,8 +5647,7 @@ jobs:
const initEntry = logEntries.find(entry => entry.type === "system" && entry.subtype === "init");
markdown += generateInformationSection(lastEntry, {
additionalInfoCallback: entry => {
- const isPremiumModel =
- initEntry && initEntry.model_info && initEntry.model_info.billing && initEntry.model_info.billing.is_premium === true;
+ const isPremiumModel = initEntry && initEntry.model_info && initEntry.model_info.billing && initEntry.model_info.billing.is_premium === true;
if (isPremiumModel) {
const premiumRequestCount = extractPremiumRequestCount(logContent);
return `**Premium Requests Consumed:** ${premiumRequestCount}\n\n`;
@@ -6301,299 +6074,151 @@ jobs:
with:
script: |
function sanitizeWorkflowName(name) {
-
return name
-
.toLowerCase()
-
.replace(/[:\\/\s]/g, "-")
-
.replace(/[^a-z0-9._-]/g, "-");
-
}
-
function main() {
-
const fs = require("fs");
-
const path = require("path");
-
try {
-
const squidLogsDir = `/tmp/gh-aw/sandbox/firewall/logs/`;
-
if (!fs.existsSync(squidLogsDir)) {
-
core.info(`No firewall logs directory found at: ${squidLogsDir}`);
-
return;
-
}
-
const files = fs.readdirSync(squidLogsDir).filter(file => file.endsWith(".log"));
-
if (files.length === 0) {
-
core.info(`No firewall log files found in: ${squidLogsDir}`);
-
return;
-
}
-
core.info(`Found ${files.length} firewall log file(s)`);
-
let totalRequests = 0;
-
let allowedRequests = 0;
-
let deniedRequests = 0;
-
const allowedDomains = new Set();
-
const deniedDomains = new Set();
-
const requestsByDomain = new Map();
-
for (const file of files) {
-
const filePath = path.join(squidLogsDir, file);
-
core.info(`Parsing firewall log: ${file}`);
-
const content = fs.readFileSync(filePath, "utf8");
-
const lines = content.split("\n").filter(line => line.trim());
-
for (const line of lines) {
-
const entry = parseFirewallLogLine(line);
-
if (!entry) {
-
continue;
-
}
-
totalRequests++;
-
const isAllowed = isRequestAllowed(entry.decision, entry.status);
-
if (isAllowed) {
-
allowedRequests++;
-
allowedDomains.add(entry.domain);
-
} else {
-
deniedRequests++;
-
deniedDomains.add(entry.domain);
-
}
-
if (!requestsByDomain.has(entry.domain)) {
-
requestsByDomain.set(entry.domain, { allowed: 0, denied: 0 });
-
}
-
const domainStats = requestsByDomain.get(entry.domain);
-
if (isAllowed) {
-
domainStats.allowed++;
-
} else {
-
domainStats.denied++;
-
}
-
}
-
}
-
const summary = generateFirewallSummary({
-
totalRequests,
-
allowedRequests,
-
deniedRequests,
-
allowedDomains: Array.from(allowedDomains).sort(),
-
deniedDomains: Array.from(deniedDomains).sort(),
-
requestsByDomain,
-
});
-
core.summary.addRaw(summary).write();
-
core.info("Firewall log summary generated successfully");
-
} catch (error) {
-
core.setFailed(error instanceof Error ? error : String(error));
-
}
-
}
-
function parseFirewallLogLine(line) {
-
const trimmed = line.trim();
-
if (!trimmed || trimmed.startsWith("#")) {
-
return null;
-
}
-
const fields = trimmed.match(/(?:[^\s"]+|"[^"]*")+/g);
-
if (!fields || fields.length < 10) {
-
return null;
-
}
-
const timestamp = fields[0];
-
if (!/^\d+(\.\d+)?$/.test(timestamp)) {
-
return null;
-
}
-
return {
-
timestamp,
-
clientIpPort: fields[1],
-
domain: fields[2],
-
destIpPort: fields[3],
-
proto: fields[4],
-
method: fields[5],
-
status: fields[6],
-
decision: fields[7],
-
url: fields[8],
-
userAgent: fields[9]?.replace(/^"|"$/g, "") || "-",
-
};
-
}
-
function isRequestAllowed(decision, status) {
-
const statusCode = parseInt(status, 10);
-
if (statusCode === 200 || statusCode === 206 || statusCode === 304) {
-
return true;
-
}
-
if (decision.includes("TCP_TUNNEL") || decision.includes("TCP_HIT") || decision.includes("TCP_MISS")) {
-
return true;
-
}
-
if (decision.includes("NONE_NONE") || decision.includes("TCP_DENIED") || statusCode === 403 || statusCode === 407) {
-
return false;
-
}
-
return false;
-
}
-
function generateFirewallSummary(analysis) {
-
const { totalRequests, requestsByDomain } = analysis;
-
const validDomains = Array.from(requestsByDomain.keys())
-
.filter(domain => domain !== "-")
-
.sort();
-
const uniqueDomainCount = validDomains.length;
-
let validAllowedRequests = 0;
-
let validDeniedRequests = 0;
-
for (const domain of validDomains) {
-
const stats = requestsByDomain.get(domain);
-
validAllowedRequests += stats.allowed;
-
validDeniedRequests += stats.denied;
-
}
-
let summary = "### 🔥 Firewall Activity\n\n";
-
summary += "\n";
-
summary += `📊 ${totalRequests} request${totalRequests !== 1 ? "s" : ""} | `;
-
summary += `${validAllowedRequests} allowed | `;
-
summary += `${validDeniedRequests} blocked | `;
-
summary += `${uniqueDomainCount} unique domain${uniqueDomainCount !== 1 ? "s" : ""}
\n\n`;
-
if (uniqueDomainCount > 0) {
-
summary += "| Domain | Allowed | Denied |\n";
-
summary += "|--------|---------|--------|\n";
-
for (const domain of validDomains) {
-
const stats = requestsByDomain.get(domain);
-
summary += `| ${domain} | ${stats.allowed} | ${stats.denied} |\n`;
-
}
-
} else {
-
summary += "No firewall activity detected.\n";
-
}
-
summary += "\n \n\n";
-
return summary;
-
}
-
- const isDirectExecution =
-
- typeof module === "undefined" || (typeof require !== "undefined" && typeof require.main !== "undefined" && require.main === module);
-
+ const isDirectExecution = typeof module === "undefined" || (typeof require !== "undefined" && typeof require.main !== "undefined" && require.main === module);
if (isDirectExecution) {
-
main();
-
}
-
- name: Upload Agent Stdio
if: always()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
@@ -6748,9 +6373,7 @@ jobs:
}
lastIndex = regex.lastIndex;
if (iterationCount === ITERATION_WARNING_THRESHOLD) {
- core.warning(
- `High iteration count (${iterationCount}) on line ${lineIndex + 1} with pattern: ${pattern.description || pattern.pattern}`
- );
+ core.warning(`High iteration count (${iterationCount}) on line ${lineIndex + 1} with pattern: ${pattern.description || pattern.pattern}`);
core.warning(`Line content (truncated): ${truncateString(line, 200)}`);
}
if (iterationCount > MAX_ITERATIONS_PER_LINE) {
@@ -7057,9 +6680,7 @@ jobs:
core.setOutput("total_count", missingTools.length.toString());
if (missingTools.length > 0) {
core.info("Missing tools summary:");
- core.summary
- .addHeading("Missing Tools Report", 3)
- .addRaw(`Found **${missingTools.length}** missing tool${missingTools.length > 1 ? "s" : ""} in this workflow execution.\n\n`);
+ core.summary.addHeading("Missing Tools Report", 3).addRaw(`Found **${missingTools.length}** missing tool${missingTools.length > 1 ? "s" : ""} in this workflow execution.\n\n`);
missingTools.forEach((tool, index) => {
core.info(`${index + 1}. Tool: ${tool.tool}`);
core.info(` Reason: ${tool.reason}`);
@@ -7192,9 +6813,7 @@ jobs:
const messages = getMessages();
const templateContext = toSnakeCase(ctx);
const defaultMessage = "⚠️ Security scanning failed for [{workflow_name}]({run_url}). Review the logs for details.";
- return messages?.detectionFailure
- ? renderTemplate(messages.detectionFailure, templateContext)
- : renderTemplate(defaultMessage, templateContext);
+ return messages?.detectionFailure ? renderTemplate(messages.detectionFailure, templateContext) : renderTemplate(defaultMessage, templateContext);
}
function collectGeneratedAssets() {
const assets = [];
@@ -7722,29 +7341,21 @@ jobs:
}
function parseProjectInput(projectUrl) {
if (!projectUrl || typeof projectUrl !== "string") {
- throw new Error(
- `Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`
- );
+ throw new Error(`Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`);
}
const urlMatch = projectUrl.match(/github\.com\/(?:users|orgs)\/[^/]+\/projects\/(\d+)/);
if (!urlMatch) {
- throw new Error(
- `Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`
- );
+ throw new Error(`Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`);
}
return urlMatch[1];
}
function parseProjectUrl(projectUrl) {
if (!projectUrl || typeof projectUrl !== "string") {
- throw new Error(
- `Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`
- );
+ throw new Error(`Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`);
}
const match = projectUrl.match(/github\.com\/(users|orgs)\/([^/]+)\/projects\/(\d+)/);
if (!match) {
- throw new Error(
- `Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`
- );
+ throw new Error(`Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`);
}
return { scope: match[1], ownerLogin: match[2], projectNumber: match[3] };
}
@@ -7955,9 +7566,7 @@ jobs:
} catch (viewerError) {
core.warning(`Could not resolve token identity (viewer.login): ${viewerError.message}`);
}
- core.info(
- `[2/5] Resolving project from URL (scope=${projectInfo.scope}, login=${projectInfo.ownerLogin}, number=${projectNumberFromUrl})...`
- );
+ core.info(`[2/5] Resolving project from URL (scope=${projectInfo.scope}, login=${projectInfo.ownerLogin}, number=${projectNumberFromUrl})...`);
let projectId;
let resolvedProjectNumber = projectNumberFromUrl;
try {
@@ -8018,12 +7627,7 @@ jobs:
let contentNumber = null;
if (hasContentNumber || hasIssue || hasPullRequest) {
const rawContentNumber = hasContentNumber ? output.content_number : hasIssue ? output.issue : output.pull_request;
- const sanitizedContentNumber =
- rawContentNumber === undefined || rawContentNumber === null
- ? ""
- : typeof rawContentNumber === "number"
- ? rawContentNumber.toString()
- : String(rawContentNumber).trim();
+ const sanitizedContentNumber = rawContentNumber === undefined || rawContentNumber === null ? "" : typeof rawContentNumber === "number" ? rawContentNumber.toString() : String(rawContentNumber).trim();
if (!sanitizedContentNumber) {
core.warning("Content number field provided but empty; skipping project item update.");
} else if (!/^\d+$/.test(sanitizedContentNumber)) {
@@ -8033,14 +7637,7 @@ jobs:
}
}
if (contentNumber !== null) {
- const contentType =
- output.content_type === "pull_request"
- ? "PullRequest"
- : output.content_type === "issue"
- ? "Issue"
- : output.issue
- ? "Issue"
- : "PullRequest";
+ const contentType = output.content_type === "pull_request" ? "PullRequest" : output.content_type === "issue" ? "Issue" : output.issue ? "Issue" : "PullRequest";
const contentQuery =
contentType === "Issue"
? `query($owner: String!, $repo: String!, $number: Int!) {
@@ -8169,8 +7766,7 @@ jobs:
.join(" ");
let field = projectFields.find(f => f.name.toLowerCase() === normalizedFieldName.toLowerCase());
if (!field) {
- const isTextField =
- fieldName.toLowerCase() === "classification" || (typeof fieldValue === "string" && fieldValue.includes("|"));
+ const isTextField = fieldName.toLowerCase() === "classification" || (typeof fieldValue === "string" && fieldValue.includes("|"));
if (isTextField) {
try {
const createFieldResult = await github.graphql(
@@ -8246,10 +7842,7 @@ jobs:
let option = field.options.find(o => o.name === fieldValue);
if (!option) {
try {
- const allOptions = [
- ...field.options.map(o => ({ name: o.name, description: "", color: o.color || "GRAY" })),
- { name: String(fieldValue), description: "", color: "GRAY" },
- ];
+ const allOptions = [...field.options.map(o => ({ name: o.name, description: "", color: o.color || "GRAY" })), { name: String(fieldValue), description: "", color: "GRAY" }];
const createOptionResult = await github.graphql(
`mutation($fieldId: ID!, $fieldName: String!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {
updateProjectV2Field(input: {
From c45709b541aed225fa18236ebb793eab9752bf0f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 18 Dec 2025 07:17:32 +0000
Subject: [PATCH 3/5] Apply terser optimization to JavaScript files - reduce
55k+ lines
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
pkg/workflow/js/add_comment.test.cjs | 1702 +++-------
pkg/workflow/js/add_copilot_reviewer.cjs | 66 +-
pkg/workflow/js/add_copilot_reviewer.test.cjs | 211 +-
pkg/workflow/js/add_labels.test.cjs | 1514 +++------
.../js/add_reaction_and_edit_comment.test.cjs | 886 ++---
pkg/workflow/js/add_reviewer.test.cjs | 384 +--
pkg/workflow/js/assign_agent_helpers.cjs | 472 +--
.../js/assign_copilot_to_created_issues.cjs | 135 +-
pkg/workflow/js/assign_issue.cjs | 109 +-
pkg/workflow/js/assign_issue.test.cjs | 503 +--
pkg/workflow/js/assign_milestone.test.cjs | 402 +--
pkg/workflow/js/assign_to_agent.cjs | 236 +-
.../js/check_command_position.test.cjs | 332 +-
pkg/workflow/js/check_membership.test.cjs | 443 +--
pkg/workflow/js/check_permissions.test.cjs | 457 +--
pkg/workflow/js/check_permissions_utils.cjs | 109 +-
.../js/check_permissions_utils.test.cjs | 326 +-
pkg/workflow/js/check_skip_if_match.test.cjs | 495 +--
pkg/workflow/js/check_stop_time.test.cjs | 267 +-
pkg/workflow/js/check_team_member.test.cjs | 385 +--
pkg/workflow/js/check_workflow_timestamp.cjs | 108 +-
.../js/check_workflow_timestamp.test.cjs | 522 ++-
.../js/check_workflow_timestamp_api.cjs | 104 +-
pkg/workflow/js/checkout_pr_branch.cjs | 56 +-
pkg/workflow/js/checkout_pr_branch.test.cjs | 364 +--
pkg/workflow/js/close_discussion.test.cjs | 509 +--
pkg/workflow/js/close_entity_helpers.cjs | 390 +--
pkg/workflow/js/close_entity_helpers.test.cjs | 472 ++-
pkg/workflow/js/close_issue.test.cjs | 548 +---
pkg/workflow/js/close_older_discussions.cjs | 277 +-
.../js/close_older_discussions.test.cjs | 842 ++---
.../js/collect_ndjson_output.test.cjs | 2904 -----------------
pkg/workflow/js/compute_text.test.cjs | 809 +----
pkg/workflow/js/create_agent_task.cjs | 180 +-
pkg/workflow/js/create_agent_task.test.cjs | 259 +-
.../js/create_code_scanning_alert.test.cjs | 545 +---
pkg/workflow/js/create_discussion.test.cjs | 483 +--
pkg/workflow/js/create_issue.cjs | 353 +-
pkg/workflow/js/create_issue.test.cjs | 989 +-----
.../js/create_pr_review_comment.test.cjs | 738 +----
pkg/workflow/js/create_pull_request.test.cjs | 1736 +---------
pkg/workflow/js/estimate_tokens.cjs | 17 +-
pkg/workflow/js/estimate_tokens.test.cjs | 53 +-
pkg/workflow/js/expiration_helpers.cjs | 28 +-
pkg/workflow/js/fuzz_mentions_harness.cjs | 52 +-
.../fuzz_sanitize_incoming_text_harness.cjs | 51 +-
.../js/fuzz_sanitize_label_harness.cjs | 50 +-
.../js/fuzz_sanitize_output_harness.cjs | 52 +-
pkg/workflow/js/generate_compact_schema.cjs | 44 +-
.../js/generate_compact_schema.test.cjs | 87 +-
pkg/workflow/js/generate_footer.cjs | 95 +-
pkg/workflow/js/generate_footer.test.cjs | 229 +-
pkg/workflow/js/generate_git_patch.cjs | 142 +-
pkg/workflow/js/generate_git_patch.test.cjs | 130 +-
.../js/generate_safe_inputs_config.cjs | 35 +-
.../js/generate_safe_inputs_config.test.cjs | 77 +-
pkg/workflow/js/get_base_branch.cjs | 15 +-
pkg/workflow/js/get_base_branch.test.cjs | 55 +-
pkg/workflow/js/get_current_branch.cjs | 45 +-
pkg/workflow/js/get_current_branch.test.cjs | 97 +-
pkg/workflow/js/get_repository_url.cjs | 30 +-
pkg/workflow/js/get_repository_url.test.cjs | 119 +-
pkg/workflow/js/get_tracker_id.cjs | 21 +-
pkg/workflow/js/get_tracker_id.test.cjs | 103 +-
pkg/workflow/js/hide_comment.test.cjs | 403 +--
pkg/workflow/js/interpolate_prompt.cjs | 127 +-
pkg/workflow/js/interpolate_prompt.test.cjs | 329 +-
.../js/interpolate_prompt_additional.test.cjs | 431 +--
pkg/workflow/js/is_truthy.cjs | 13 +-
pkg/workflow/js/is_truthy.test.cjs | 61 +-
pkg/workflow/js/link_sub_issue.test.cjs | 356 +-
pkg/workflow/js/load_agent_output.cjs | 91 +-
pkg/workflow/js/load_agent_output.test.cjs | 249 +-
pkg/workflow/js/lock-issue.test.cjs | 241 +-
pkg/workflow/js/log_parser_bootstrap.cjs | 140 +-
pkg/workflow/js/log_parser_bootstrap.test.cjs | 325 +-
pkg/workflow/js/log_parser_shared.cjs | 1401 +-------
pkg/workflow/js/log_parser_shared.test.cjs | 1825 +----------
pkg/workflow/js/mcp_handler_python.cjs | 101 +-
pkg/workflow/js/mcp_handler_shell.cjs | 147 +-
pkg/workflow/js/mcp_http_transport.cjs | 296 +-
pkg/workflow/js/mcp_http_transport.test.cjs | 254 +-
pkg/workflow/js/mcp_logger.cjs | 54 +-
pkg/workflow/js/mcp_logger.test.cjs | 107 +-
pkg/workflow/js/mcp_server_core.cjs | 748 +----
pkg/workflow/js/mcp_server_core.test.cjs | 901 +----
pkg/workflow/js/messages.cjs | 59 +-
pkg/workflow/js/messages.test.cjs | 596 +---
pkg/workflow/js/messages_close_discussion.cjs | 46 +-
pkg/workflow/js/messages_core.cjs | 92 +-
pkg/workflow/js/messages_footer.cjs | 172 +-
pkg/workflow/js/messages_run_status.cjs | 117 +-
pkg/workflow/js/messages_staged.cjs | 58 +-
pkg/workflow/js/missing_tool.cjs | 139 +-
pkg/workflow/js/missing_tool.test.cjs | 356 +-
pkg/workflow/js/noop.test.cjs | 195 +-
pkg/workflow/js/normalize_branch_name.cjs | 55 +-
.../js/normalize_branch_name.test.cjs | 83 +-
pkg/workflow/js/notify_comment_error.cjs | 213 +-
pkg/workflow/js/notify_comment_error.test.cjs | 458 +--
pkg/workflow/js/package-lock.json | 479 ++-
pkg/workflow/js/package.json | 4 +-
pkg/workflow/js/parse_claude_log.cjs | 125 +-
pkg/workflow/js/parse_claude_log.test.cjs | 1014 +-----
pkg/workflow/js/parse_codex_log.cjs | 466 +--
pkg/workflow/js/parse_codex_log.test.cjs | 494 +--
pkg/workflow/js/parse_copilot_log.cjs | 694 +---
pkg/workflow/js/parse_copilot_log.test.cjs | 1250 +------
pkg/workflow/js/parse_firewall_logs.cjs | 227 +-
pkg/workflow/js/parse_firewall_logs.test.cjs | 257 +-
pkg/workflow/js/push_repo_memory.cjs | 215 +-
.../js/push_to_pull_request_branch.test.cjs | 1189 +------
pkg/workflow/js/read_buffer.cjs | 68 +-
pkg/workflow/js/read_buffer.test.cjs | 191 +-
pkg/workflow/js/redact_secrets.test.cjs | 247 +-
pkg/workflow/js/remove_duplicate_title.cjs | 51 +-
.../js/remove_duplicate_title.test.cjs | 361 +-
pkg/workflow/js/render_template.cjs | 88 +-
pkg/workflow/js/render_template.test.cjs | 339 +-
pkg/workflow/js/repo_helpers.cjs | 81 +-
pkg/workflow/js/repo_helpers.test.cjs | 143 +-
pkg/workflow/js/resolve_mentions.cjs | 195 +-
pkg/workflow/js/resolve_mentions.test.cjs | 292 +-
.../js/resolve_mentions_from_payload.cjs | 199 +-
pkg/workflow/js/runtime_import.cjs | 149 +-
pkg/workflow/js/runtime_import.test.cjs | 436 +--
pkg/workflow/js/safe_inputs_bootstrap.cjs | 81 +-
.../js/safe_inputs_bootstrap.test.cjs | 169 +-
pkg/workflow/js/safe_inputs_config_loader.cjs | 54 +-
.../js/safe_inputs_config_loader.test.cjs | 134 +-
pkg/workflow/js/safe_inputs_mcp_server.cjs | 114 +-
.../js/safe_inputs_mcp_server.test.cjs | 849 +----
.../js/safe_inputs_mcp_server_http.cjs | 341 +-
.../js/safe_inputs_mcp_server_http.test.cjs | 357 +-
pkg/workflow/js/safe_inputs_tool_factory.cjs | 38 +-
.../js/safe_inputs_tool_factory.test.cjs | 71 +-
pkg/workflow/js/safe_inputs_validation.cjs | 33 +-
.../js/safe_inputs_validation.test.cjs | 139 +-
pkg/workflow/js/safe_output_helpers.cjs | 171 +-
pkg/workflow/js/safe_output_helpers.test.cjs | 354 +-
pkg/workflow/js/safe_output_processor.cjs | 257 +-
.../js/safe_output_processor.test.cjs | 386 +--
.../js/safe_output_type_validator.cjs | 569 +---
.../js/safe_output_type_validator.test.cjs | 469 +--
pkg/workflow/js/safe_output_validator.cjs | 165 +-
.../js/safe_output_validator.test.cjs | 284 +-
pkg/workflow/js/safe_outputs_append.cjs | 36 +-
pkg/workflow/js/safe_outputs_append.test.cjs | 246 +-
pkg/workflow/js/safe_outputs_bootstrap.cjs | 75 +-
.../js/safe_outputs_bootstrap.test.cjs | 154 +-
.../js/safe_outputs_branch_detection.test.cjs | 106 +-
pkg/workflow/js/safe_outputs_config.cjs | 60 +-
pkg/workflow/js/safe_outputs_config.test.cjs | 168 +-
pkg/workflow/js/safe_outputs_handlers.cjs | 323 +-
.../js/safe_outputs_handlers.test.cjs | 267 +-
pkg/workflow/js/safe_outputs_mcp_client.cjs | 136 +-
.../js/safe_outputs_mcp_client.test.cjs | 202 +-
.../safe_outputs_mcp_large_content.test.cjs | 539 +--
pkg/workflow/js/safe_outputs_mcp_server.cjs | 81 +-
.../js/safe_outputs_mcp_server.test.cjs | 403 +--
.../safe_outputs_mcp_server_defaults.test.cjs | 783 +----
.../js/safe_outputs_mcp_server_patch.test.cjs | 218 +-
pkg/workflow/js/safe_outputs_tools_loader.cjs | 166 +-
.../js/safe_outputs_tools_loader.test.cjs | 322 +-
.../js/safe_outputs_type_validation.test.cjs | 113 +-
pkg/workflow/js/sanitize_content.cjs | 289 +-
pkg/workflow/js/sanitize_content.test.cjs | 707 +---
pkg/workflow/js/sanitize_content_core.cjs | 397 +--
pkg/workflow/js/sanitize_content_old.cjs | 405 +--
pkg/workflow/js/sanitize_incoming_text.cjs | 28 +-
pkg/workflow/js/sanitize_label_content.cjs | 30 +-
.../js/sanitize_label_content.test.cjs | 143 +-
pkg/workflow/js/sanitize_output.test.cjs | 1099 +------
pkg/workflow/js/sanitize_workflow_name.cjs | 15 +-
.../js/sanitize_workflow_name.test.cjs | 47 +-
pkg/workflow/js/staged_preview.cjs | 36 +-
pkg/workflow/js/substitute_placeholders.cjs | 51 +-
.../js/substitute_placeholders.test.cjs | 134 +-
pkg/workflow/js/temporary_id.cjs | 182 +-
pkg/workflow/js/temporary_id.test.cjs | 283 +-
pkg/workflow/js/unlock-issue.test.cjs | 249 +-
pkg/workflow/js/update_activation_comment.cjs | 156 +-
.../js/update_activation_comment.test.cjs | 438 +--
pkg/workflow/js/update_context_helpers.cjs | 70 +-
.../js/update_context_helpers.test.cjs | 183 +-
pkg/workflow/js/update_issue.test.cjs | 328 +-
.../js/update_pr_description_helpers.cjs | 130 +-
pkg/workflow/js/update_project.cjs | 822 +----
pkg/workflow/js/update_project.test.cjs | 491 +--
pkg/workflow/js/update_pull_request.test.cjs | 737 +----
pkg/workflow/js/update_release.test.cjs | 398 +--
pkg/workflow/js/update_runner.cjs | 352 +-
pkg/workflow/js/update_runner.test.cjs | 609 +---
pkg/workflow/js/upload_assets.test.cjs | 434 +--
pkg/workflow/js/validate_errors.cjs | 354 +-
.../js/write_large_content_to_file.cjs | 45 +-
.../js/write_large_content_to_file.test.cjs | 118 +-
197 files changed, 4440 insertions(+), 59495 deletions(-)
diff --git a/pkg/workflow/js/add_comment.test.cjs b/pkg/workflow/js/add_comment.test.cjs
index ef7867c95d7..c432f9f6513 100644
--- a/pkg/workflow/js/add_comment.test.cjs
+++ b/pkg/workflow/js/add_comment.test.cjs
@@ -1,1262 +1,452 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import fs from "fs";
import path from "path";
-
-// Mock the global objects that GitHub Actions provides
const mockCore = {
- // Core logging functions
- debug: vi.fn(),
- info: vi.fn(),
- notice: vi.fn(),
- warning: vi.fn(),
- error: vi.fn(),
-
- // Core workflow functions
- setFailed: vi.fn(),
- setOutput: vi.fn(),
- exportVariable: vi.fn(),
- setSecret: vi.fn(),
-
- // Input/state functions (less commonly used but included for completeness)
- getInput: vi.fn(),
- getBooleanInput: vi.fn(),
- getMultilineInput: vi.fn(),
- getState: vi.fn(),
- saveState: vi.fn(),
-
- // Group functions
- startGroup: vi.fn(),
- endGroup: vi.fn(),
- group: vi.fn(),
-
- // Other utility functions
- addPath: vi.fn(),
- setCommandEcho: vi.fn(),
- isDebug: vi.fn().mockReturnValue(false),
- getIDToken: vi.fn(),
- toPlatformPath: vi.fn(),
- toPosixPath: vi.fn(),
- toWin32Path: vi.fn(),
-
- // Summary object with chainable methods
- summary: {
- addRaw: vi.fn().mockReturnThis(),
- write: vi.fn().mockResolvedValue(),
+ debug: vi.fn(),
+ info: vi.fn(),
+ notice: vi.fn(),
+ warning: vi.fn(),
+ error: vi.fn(),
+ setFailed: vi.fn(),
+ setOutput: vi.fn(),
+ exportVariable: vi.fn(),
+ setSecret: vi.fn(),
+ getInput: vi.fn(),
+ getBooleanInput: vi.fn(),
+ getMultilineInput: vi.fn(),
+ getState: vi.fn(),
+ saveState: vi.fn(),
+ startGroup: vi.fn(),
+ endGroup: vi.fn(),
+ group: vi.fn(),
+ addPath: vi.fn(),
+ setCommandEcho: vi.fn(),
+ isDebug: vi.fn().mockReturnValue(!1),
+ getIDToken: vi.fn(),
+ toPlatformPath: vi.fn(),
+ toPosixPath: vi.fn(),
+ toWin32Path: vi.fn(),
+ summary: { addRaw: vi.fn().mockReturnThis(), write: vi.fn().mockResolvedValue() },
},
-};
-
-const mockGithub = {
- rest: {
- issues: {
- createComment: vi.fn(),
- },
- },
-};
-
-const mockContext = {
- eventName: "issues",
- runId: 12345,
- repo: {
- owner: "testowner",
- repo: "testrepo",
- },
- payload: {
- issue: {
- number: 123,
- },
- repository: {
- html_url: "https://github.com/testowner/testrepo",
- },
- },
-};
-
-// Set up global variables
-global.core = mockCore;
-global.github = mockGithub;
-global.context = mockContext;
-
-describe("add_comment.cjs", () => {
- let createCommentScript;
-
- let tempFilePath;
-
- // Helper function to set agent output via file
- const setAgentOutput = data => {
- tempFilePath = path.join("/tmp", `test_agent_output_${Date.now()}_${Math.random().toString(36).slice(2)}.json`);
- const content = typeof data === "string" ? data : JSON.stringify(data);
- fs.writeFileSync(tempFilePath, content);
- process.env.GH_AW_AGENT_OUTPUT = tempFilePath;
- };
-
- beforeEach(() => {
- // Reset all mocks
- vi.clearAllMocks();
-
- // Reset environment variables
- delete process.env.GH_AW_AGENT_OUTPUT;
- delete process.env.GITHUB_WORKFLOW;
-
- // Reset context to default state
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 123 };
-
- // Read the script content
- const scriptPath = path.join(process.cwd(), "add_comment.cjs");
- createCommentScript = fs.readFileSync(scriptPath, "utf8");
- });
-
- afterEach(() => {
- // Clean up temporary file
- if (tempFilePath && require("fs").existsSync(tempFilePath)) {
- require("fs").unlinkSync(tempFilePath);
- tempFilePath = undefined;
- }
- });
-
- it("should skip when no agent output is provided", async () => {
- // Remove the output content environment variable
- delete process.env.GH_AW_AGENT_OUTPUT;
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("No GH_AW_AGENT_OUTPUT environment variable found");
- expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled();
- });
-
- it("should skip when agent output is empty", async () => {
- setAgentOutput("");
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("Agent output content is empty");
- expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled();
- });
-
- it("should skip when not in issue or PR context", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test comment content",
- },
- ],
- });
- global.context.eventName = "push"; // Not an issue or PR event
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation');
- expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled();
- });
-
- it("should create comment on issue successfully", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test comment content",
- },
- ],
- });
- global.context.eventName = "issues";
-
- const mockComment = {
- id: 456,
- html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456",
- };
-
- mockGithub.rest.issues.createComment.mockResolvedValue({
- data: mockComment,
- });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- body: expect.stringContaining("Test comment content"),
- });
-
- expect(mockCore.setOutput).toHaveBeenCalledWith("comment_id", 456);
- expect(mockCore.setOutput).toHaveBeenCalledWith("comment_url", mockComment.html_url);
- expect(mockCore.summary.addRaw).toHaveBeenCalled();
- expect(mockCore.summary.write).toHaveBeenCalled();
- });
-
- it("should create comment on pull request successfully", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test PR comment content",
- },
- ],
- });
- global.context.eventName = "pull_request";
- global.context.payload.pull_request = { number: 789 };
- delete global.context.payload.issue; // Remove issue from payload
-
- const mockComment = {
- id: 789,
- html_url: "https://github.com/testowner/testrepo/issues/789#issuecomment-789",
- };
-
- mockGithub.rest.issues.createComment.mockResolvedValue({
- data: mockComment,
- });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 789,
- body: expect.stringContaining("Test PR comment content"),
- });
- });
-
- it("should include run information in comment body", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test content",
- },
- ],
- });
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 123 }; // Make sure issue context is properly set
-
- const mockComment = {
- id: 456,
- html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456",
- };
-
- mockGithub.rest.issues.createComment.mockResolvedValue({
- data: mockComment,
- });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalled();
- expect(mockGithub.rest.issues.createComment.mock.calls).toHaveLength(1);
-
- const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
- expect(callArgs.body).toContain("Test content");
- // Now uses messages.cjs with pirate-themed default footer
- expect(callArgs.body).toContain("This treasure was crafted by");
- expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345");
- });
-
- it("should include workflow source in footer when GH_AW_WORKFLOW_SOURCE is provided", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test content with source",
- },
- ],
- });
- process.env.GH_AW_WORKFLOW_NAME = "Test Workflow";
- process.env.GH_AW_WORKFLOW_SOURCE = "githubnext/agentics/workflows/ci-doctor.md@v1.0.0";
- process.env.GH_AW_WORKFLOW_SOURCE_URL = "https://github.com/githubnext/agentics/tree/v1.0.0/workflows/ci-doctor.md";
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 123 };
-
- const mockComment = {
- id: 456,
- html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456",
- };
-
- mockGithub.rest.issues.createComment.mockResolvedValue({
- data: mockComment,
- });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalled();
- const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
-
- // Check that the footer contains the expected elements (now using messages.cjs with pirate-themed footer)
- expect(callArgs.body).toContain("Test content with source");
- expect(callArgs.body).toContain("[🏴☠️ Test Workflow]");
- expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345");
- expect(callArgs.body).toContain("gh aw add githubnext/agentics/workflows/ci-doctor.md@v1.0.0");
- });
-
- it("should not include workflow source footer when GH_AW_WORKFLOW_SOURCE is not provided", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test content without source",
- },
- ],
- });
- process.env.GH_AW_WORKFLOW_NAME = "Test Workflow";
- delete process.env.GH_AW_WORKFLOW_SOURCE; // Ensure it's not set
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 123 };
-
- const mockComment = {
- id: 456,
- html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456",
- };
-
- mockGithub.rest.issues.createComment.mockResolvedValue({
- data: mockComment,
- });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalled();
- const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
-
- // Check that the footer does NOT contain the workflow source (now using messages.cjs with pirate-themed footer)
- expect(callArgs.body).toContain("Test content without source");
- expect(callArgs.body).toContain("[🏴☠️ Test Workflow]");
- expect(callArgs.body).not.toContain("gh aw add");
- });
-
- it("should use GITHUB_SERVER_URL when repository context is not available", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test content with custom server",
- },
- ],
- });
- process.env.GITHUB_SERVER_URL = "https://github.enterprise.com";
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 123 };
- // Remove repository context to force use of GITHUB_SERVER_URL
- delete global.context.payload.repository;
-
- const mockComment = {
- id: 456,
- html_url: "https://github.enterprise.com/testowner/testrepo/issues/123#issuecomment-456",
- };
-
- mockGithub.rest.issues.createComment.mockResolvedValue({
- data: mockComment,
- });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalled();
- const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
-
- // Check that the footer uses the custom GitHub server URL
- expect(callArgs.body).toContain("Test content with custom server");
- expect(callArgs.body).toContain("https://github.enterprise.com/testowner/testrepo/actions/runs/12345");
- expect(callArgs.body).not.toContain("https://github.com/testowner/testrepo/actions/runs/12345");
-
- // Clean up
- delete process.env.GITHUB_SERVER_URL;
- });
-
- it("should fallback to https://github.com when GITHUB_SERVER_URL is not set and repository context is missing", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test content with fallback",
- },
- ],
- });
- delete process.env.GITHUB_SERVER_URL;
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 123 };
- // Remove repository context to test fallback
- delete global.context.payload.repository;
-
- const mockComment = {
- id: 456,
- html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456",
- };
-
- mockGithub.rest.issues.createComment.mockResolvedValue({
- data: mockComment,
- });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalled();
- const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
-
- // Check that the footer uses the default https://github.com
- expect(callArgs.body).toContain("Test content with fallback");
- expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345");
- });
-
- it("should include triggering issue number in footer when in issue context", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Comment from issue context",
- },
- ],
- });
- process.env.GH_AW_WORKFLOW_NAME = "Test Workflow";
-
- // Simulate issue context
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 42 };
-
- const mockComment = {
- id: 789,
- html_url: "https://github.com/testowner/testrepo/issues/42#issuecomment-789",
- };
-
- mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
-
- // Check that the footer includes reference to triggering issue (now using messages.cjs with pirate-themed footer)
- expect(callArgs.body).toContain("Comment from issue context");
- expect(callArgs.body).toContain("[🏴☠️ Test Workflow]");
- expect(callArgs.body).toContain("#42");
- });
-
- it("should include triggering PR number in footer when in PR context", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Comment from PR context",
- },
- ],
- });
- process.env.GH_AW_WORKFLOW_NAME = "Test Workflow";
-
- // Simulate PR context
- global.context.eventName = "pull_request";
- delete global.context.payload.issue;
- global.context.payload.pull_request = { number: 123 };
-
- const mockComment = {
- id: 890,
- html_url: "https://github.com/testowner/testrepo/pull/123#issuecomment-890",
- };
-
- mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
-
- // Check that the footer includes reference to triggering PR (now using messages.cjs with pirate-themed footer)
- expect(callArgs.body).toContain("Comment from PR context");
- expect(callArgs.body).toContain("[🏴☠️ Test Workflow]");
- expect(callArgs.body).toContain("#123");
-
- // Clean up
- delete global.context.payload.pull_request;
- });
-
- it("should use header level 4 for related items in comments", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test comment with related items",
- },
- ],
- });
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 123 };
-
- // Set environment variables for created items
- process.env.GH_AW_CREATED_ISSUE_URL = "https://github.com/testowner/testrepo/issues/456";
- process.env.GH_AW_CREATED_ISSUE_NUMBER = "456";
- process.env.GH_AW_CREATED_DISCUSSION_URL = "https://github.com/testowner/testrepo/discussions/789";
- process.env.GH_AW_CREATED_DISCUSSION_NUMBER = "789";
- process.env.GH_AW_CREATED_PULL_REQUEST_URL = "https://github.com/testowner/testrepo/pull/101";
- process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER = "101";
-
- const mockComment = {
- id: 890,
- html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-890",
- };
-
- mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
-
- // Check that the related items section uses header level 4 (####)
- expect(callArgs.body).toContain("#### Related Items");
- // Check that it uses exactly 4 hashes, not 2
- expect(callArgs.body).toMatch(/####\s+Related Items/);
- expect(callArgs.body).not.toMatch(/^##\s+Related Items/m);
- expect(callArgs.body).not.toMatch(/\*\*Related Items:\*\*/);
-
- // Check that the references are included
- expect(callArgs.body).toContain("- Issue: [#456](https://github.com/testowner/testrepo/issues/456)");
- expect(callArgs.body).toContain("- Discussion: [#789](https://github.com/testowner/testrepo/discussions/789)");
- expect(callArgs.body).toContain("- Pull Request: [#101](https://github.com/testowner/testrepo/pull/101)");
-
- // Clean up
- delete process.env.GH_AW_CREATED_ISSUE_URL;
- delete process.env.GH_AW_CREATED_ISSUE_NUMBER;
- delete process.env.GH_AW_CREATED_DISCUSSION_URL;
- delete process.env.GH_AW_CREATED_DISCUSSION_NUMBER;
- delete process.env.GH_AW_CREATED_PULL_REQUEST_URL;
- delete process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER;
- });
-
- it("should use header level 4 for related items in staged mode preview", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test comment in staged mode",
- },
- ],
- });
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 123 };
-
- // Enable staged mode
- process.env.GH_AW_SAFE_OUTPUTS_STAGED = "true";
-
- // Set environment variables for created items
- process.env.GH_AW_CREATED_ISSUE_URL = "https://github.com/testowner/testrepo/issues/456";
- process.env.GH_AW_CREATED_ISSUE_NUMBER = "456";
- process.env.GH_AW_CREATED_DISCUSSION_URL = "https://github.com/testowner/testrepo/discussions/789";
- process.env.GH_AW_CREATED_DISCUSSION_NUMBER = "789";
- process.env.GH_AW_CREATED_PULL_REQUEST_URL = "https://github.com/testowner/testrepo/pull/101";
- process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER = "101";
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- // Check that summary was written with correct header level 4
- expect(mockCore.summary.addRaw).toHaveBeenCalled();
- const summaryContent = mockCore.summary.addRaw.mock.calls[0][0];
-
- // Check that the related items section uses header level 4 (####)
- expect(summaryContent).toContain("#### Related Items");
- // Check that it uses exactly 4 hashes, not 2
- expect(summaryContent).toMatch(/####\s+Related Items/);
- expect(summaryContent).not.toMatch(/^##\s+Related Items/m);
- expect(summaryContent).not.toMatch(/\*\*Related Items:\*\*/);
-
- // Check that the references are included
- expect(summaryContent).toContain("- Issue: [#456](https://github.com/testowner/testrepo/issues/456)");
- expect(summaryContent).toContain("- Discussion: [#789](https://github.com/testowner/testrepo/discussions/789)");
- expect(summaryContent).toContain("- Pull Request: [#101](https://github.com/testowner/testrepo/pull/101)");
-
- // Clean up
- delete process.env.GH_AW_SAFE_OUTPUTS_STAGED;
- delete process.env.GH_AW_CREATED_ISSUE_URL;
- delete process.env.GH_AW_CREATED_ISSUE_NUMBER;
- delete process.env.GH_AW_CREATED_DISCUSSION_URL;
- delete process.env.GH_AW_CREATED_DISCUSSION_NUMBER;
- delete process.env.GH_AW_CREATED_PULL_REQUEST_URL;
- delete process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER;
- });
-
- it("should create comment on discussion using GraphQL when in discussion_comment context", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test discussion comment",
- },
- ],
- });
-
- // Simulate discussion_comment context
- global.context.eventName = "discussion_comment";
- global.context.payload.discussion = { number: 1993 };
- global.context.payload.comment = {
- id: 12345,
- node_id: "DC_kwDOABcD1M4AaBbC", // Node ID of the comment to reply to
- };
- delete global.context.payload.issue;
- delete global.context.payload.pull_request;
-
- // Mock GraphQL responses for discussion
- const mockGraphqlResponse = vi.fn();
- mockGraphqlResponse
- .mockResolvedValueOnce({
- // First call: get discussion ID
- repository: {
- discussion: {
- id: "D_kwDOPc1QR84BpqRs",
- url: "https://github.com/testowner/testrepo/discussions/1993",
- },
- },
- })
- .mockResolvedValueOnce({
- // Second call: create comment with replyToId
- addDiscussionComment: {
- comment: {
- id: "DC_kwDOPc1QR84BpqRt",
- body: "Test discussion comment",
- createdAt: "2025-10-19T22:00:00Z",
- url: "https://github.com/testowner/testrepo/discussions/1993#discussioncomment-123",
- },
- },
- });
-
- global.github.graphql = mockGraphqlResponse;
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- // Verify GraphQL was called with correct queries
- expect(mockGraphqlResponse).toHaveBeenCalledTimes(2);
-
- // First call should fetch discussion ID
- expect(mockGraphqlResponse.mock.calls[0][0]).toContain("query");
- expect(mockGraphqlResponse.mock.calls[0][0]).toContain("discussion(number: $num)");
- expect(mockGraphqlResponse.mock.calls[0][1]).toEqual({
- owner: "testowner",
- repo: "testrepo",
- num: 1993,
- });
-
- // Second call should create the comment with replyToId
- expect(mockGraphqlResponse.mock.calls[1][0]).toContain("mutation");
- expect(mockGraphqlResponse.mock.calls[1][0]).toContain("addDiscussionComment");
- expect(mockGraphqlResponse.mock.calls[1][0]).toContain("replyToId");
- expect(mockGraphqlResponse.mock.calls[1][1].body).toContain("Test discussion comment");
- expect(mockGraphqlResponse.mock.calls[1][1].replyToId).toBe("DC_kwDOABcD1M4AaBbC");
-
- // Verify REST API was NOT called
- expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled();
-
- // Verify outputs were set
- expect(mockCore.setOutput).toHaveBeenCalledWith("comment_id", "DC_kwDOPc1QR84BpqRt");
- expect(mockCore.setOutput).toHaveBeenCalledWith("comment_url", "https://github.com/testowner/testrepo/discussions/1993#discussioncomment-123");
-
- // Clean up
- delete global.github.graphql;
- delete global.context.payload.discussion;
- delete global.context.payload.comment;
- });
-
- it("should create comment on discussion using GraphQL when GITHUB_AW_COMMENT_DISCUSSION is true (explicit discussion mode)", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test explicit discussion comment",
- item_number: 2001,
- },
- ],
- });
-
- // Set target configuration to use explicit number
- process.env.GH_AW_COMMENT_TARGET = "*";
- // Force discussion mode via environment variable
- process.env.GITHUB_AW_COMMENT_DISCUSSION = "true";
-
- // Use a non-discussion context (e.g., issues) to test explicit override
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 123 };
- delete global.context.payload.discussion;
- delete global.context.payload.pull_request;
-
- // Mock GraphQL responses for discussion
- const mockGraphqlResponse = vi.fn();
- mockGraphqlResponse
- .mockResolvedValueOnce({
- // First call: get discussion ID
- repository: {
- discussion: {
- id: "D_kwDOPc1QR84BpqRu",
- url: "https://github.com/testowner/testrepo/discussions/2001",
- },
- },
- })
- .mockResolvedValueOnce({
- // Second call: create comment (no replyToId for non-comment context)
- addDiscussionComment: {
- comment: {
- id: "DC_kwDOPc1QR84BpqRv",
- body: "Test explicit discussion comment",
- createdAt: "2025-10-22T12:00:00Z",
- url: "https://github.com/testowner/testrepo/discussions/2001#discussioncomment-456",
- },
- },
- });
-
- global.github.graphql = mockGraphqlResponse;
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- // Verify GraphQL was called with correct queries
- expect(mockGraphqlResponse).toHaveBeenCalledTimes(2);
-
- // First call should fetch discussion ID for the explicit number
- expect(mockGraphqlResponse.mock.calls[0][0]).toContain("query");
- expect(mockGraphqlResponse.mock.calls[0][0]).toContain("discussion(number: $num)");
- expect(mockGraphqlResponse.mock.calls[0][1]).toEqual({
- owner: "testowner",
- repo: "testrepo",
- num: 2001, // Should use the item_number from the comment item
- });
-
- // Second call should create the comment (without replyToId since this is not discussion_comment context)
- expect(mockGraphqlResponse.mock.calls[1][0]).toContain("mutation");
- expect(mockGraphqlResponse.mock.calls[1][0]).toContain("addDiscussionComment");
- expect(mockGraphqlResponse.mock.calls[1][1].body).toContain("Test explicit discussion comment");
- // Should NOT have replyToId since we're not in discussion_comment context
- expect(mockGraphqlResponse.mock.calls[1][1].replyToId).toBeUndefined();
-
- // Verify REST API was NOT called (should use GraphQL for discussions)
- expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled();
-
- // Verify outputs were set
- expect(mockCore.setOutput).toHaveBeenCalledWith("comment_id", "DC_kwDOPc1QR84BpqRv");
- expect(mockCore.setOutput).toHaveBeenCalledWith("comment_url", "https://github.com/testowner/testrepo/discussions/2001#discussioncomment-456");
-
- // Verify info logging shows it's targeting a discussion
- expect(mockCore.info).toHaveBeenCalledWith("Creating comment on discussion #2001");
-
- // Clean up
- delete process.env.GH_AW_COMMENT_TARGET;
- delete process.env.GITHUB_AW_COMMENT_DISCUSSION;
- delete global.github.graphql;
- });
-
- it("should replace temporary ID references in comment body using the temporary ID map", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "This comment references issue #aw_aabbccdd1122 which was created earlier.",
- },
- ],
- });
-
- // Set up the temporary ID map from the create_issue job
- process.env.GH_AW_TEMPORARY_ID_MAP = JSON.stringify({ aw_aabbccdd1122: 456 });
-
- mockGithub.rest.issues.createComment.mockResolvedValue({
- data: {
- id: 99999,
- html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-99999",
- },
- });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- // The comment body should have the temporary ID replaced
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith(
- expect.objectContaining({
- body: expect.stringContaining("#456"),
- })
- );
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith(
- expect.objectContaining({
- body: expect.not.stringContaining("#aw_aabbccdd1122"),
- })
- );
-
- // Clean up
- delete process.env.GH_AW_TEMPORARY_ID_MAP;
- });
-
- it("should load temporary ID map and log the count", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test comment",
- },
- ],
- });
-
- // Set up the temporary ID map
- process.env.GH_AW_TEMPORARY_ID_MAP = JSON.stringify({ aw_abc123: 100, aw_def456: 200 });
-
- mockGithub.rest.issues.createComment.mockResolvedValue({
- data: {
- id: 99999,
- html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-99999",
- },
- });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- // Should log that the map was loaded with 2 entries
- expect(mockCore.info).toHaveBeenCalledWith("Loaded temporary ID map with 2 entries");
-
- // Clean up
- delete process.env.GH_AW_TEMPORARY_ID_MAP;
- });
-
- it("should handle empty temporary ID map gracefully", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Comment with #aw_000000000000 that won't be resolved",
- },
- ],
- });
-
- // Empty or missing temporary ID map
- process.env.GH_AW_TEMPORARY_ID_MAP = "{}";
-
- mockGithub.rest.issues.createComment.mockResolvedValue({
- data: {
- id: 99999,
- html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-99999",
- },
- });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- // The unresolved reference should remain in the body
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith(
- expect.objectContaining({
- body: expect.stringContaining("#aw_000000000000"),
- })
- );
-
- // Clean up
- delete process.env.GH_AW_TEMPORARY_ID_MAP;
- });
-
- it("should use custom footer message when GH_AW_SAFE_OUTPUT_MESSAGES is configured", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test comment with custom footer",
- },
- ],
- });
- process.env.GH_AW_WORKFLOW_NAME = "Custom Workflow";
- process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({
- footer: "> Custom AI footer by [{workflow_name}]({run_url})",
- footerInstall: "> Custom install: `gh aw add {workflow_source}`",
- });
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 456 };
-
- const mockComment = {
- id: 999,
- html_url: "https://github.com/testowner/testrepo/issues/456#issuecomment-999",
- };
-
- mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
-
- // Check that the custom footer is used
- expect(callArgs.body).toContain("Test comment with custom footer");
- expect(callArgs.body).toContain("Custom AI footer by [Custom Workflow]");
- expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345");
- // Should NOT contain the pirate-themed default footer
- expect(callArgs.body).not.toContain("Ahoy!");
- expect(callArgs.body).not.toContain("treasure was crafted");
-
- // Clean up
- delete process.env.GH_AW_SAFE_OUTPUT_MESSAGES;
- });
-
- it("should use custom footer with install instructions when workflow source is provided", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test comment with custom footer and install",
- },
- ],
- });
- process.env.GH_AW_WORKFLOW_NAME = "Custom Workflow";
- process.env.GH_AW_WORKFLOW_SOURCE = "owner/repo/workflow.md@main";
- process.env.GH_AW_WORKFLOW_SOURCE_URL = "https://github.com/owner/repo";
- process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({
- footer: "> Generated by [{workflow_name}]({run_url})",
- footerInstall: "> Install: `gh aw add {workflow_source}`",
- });
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 789 };
-
- const mockComment = {
- id: 1001,
- html_url: "https://github.com/testowner/testrepo/issues/789#issuecomment-1001",
- };
-
- mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
-
- // Check that the custom footer and install instructions are used
- expect(callArgs.body).toContain("Test comment with custom footer and install");
- expect(callArgs.body).toContain("Generated by [Custom Workflow]");
- expect(callArgs.body).toContain("Install: `gh aw add owner/repo/workflow.md@main`");
- // Should NOT contain the pirate-themed default messages
- expect(callArgs.body).not.toContain("Ahoy!");
- expect(callArgs.body).not.toContain("plunder this workflow");
-
- // Clean up
- delete process.env.GH_AW_SAFE_OUTPUT_MESSAGES;
- delete process.env.GH_AW_WORKFLOW_SOURCE;
- delete process.env.GH_AW_WORKFLOW_SOURCE_URL;
- });
-
- it("should hide older comments when hide-older-comments is enabled", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "New comment from workflow",
- },
- ],
- });
- process.env.GITHUB_WORKFLOW = "test-workflow-123";
- process.env.GH_AW_HIDE_OLDER_COMMENTS = "true";
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 100 };
-
- // Mock existing comments with the same workflow-id
- mockGithub.rest.issues.listComments = vi.fn().mockResolvedValue({
- data: [
- {
- id: 1,
- node_id: "IC_oldcomment1",
- body: "Old comment 1\n\n",
- },
- {
- id: 2,
- node_id: "IC_oldcomment2",
- body: "Old comment 2\n\n",
- },
- {
- id: 3,
- node_id: "IC_othercomment",
- body: "Comment from different workflow",
- },
- ],
- });
-
- // Mock the minimizeComment GraphQL mutation
- mockGithub.graphql = vi.fn().mockResolvedValue({
- minimizeComment: {
- minimizedComment: {
- isMinimized: true,
- },
- },
- });
-
- const mockNewComment = {
- id: 4,
- html_url: "https://github.com/testowner/testrepo/issues/100#issuecomment-4",
- };
- mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- // Verify that listComments was called
- expect(mockGithub.rest.issues.listComments).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 100,
- per_page: 100,
- page: 1,
- });
-
- // Verify that minimizeComment was called twice (for the two matching comments)
- expect(mockGithub.graphql).toHaveBeenCalledTimes(2);
- expect(mockGithub.graphql).toHaveBeenCalledWith(
- expect.stringContaining("minimizeComment"),
- expect.objectContaining({
- nodeId: "IC_oldcomment1",
- classifier: "OUTDATED",
- })
- );
- expect(mockGithub.graphql).toHaveBeenCalledWith(
- expect.stringContaining("minimizeComment"),
- expect.objectContaining({
- nodeId: "IC_oldcomment2",
- classifier: "OUTDATED",
- })
- );
-
- // Verify that the new comment was created
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith(
- expect.objectContaining({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 100,
- body: expect.stringContaining("New comment from workflow"),
- })
- );
-
- // Clean up
- delete process.env.GITHUB_WORKFLOW;
- delete process.env.GH_AW_HIDE_OLDER_COMMENTS;
- });
-
- it("should not hide comments when hide-older-comments is not enabled", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "New comment without hiding",
- },
- ],
- });
- process.env.GITHUB_WORKFLOW = "test-workflow-456";
- // Note: GH_AW_HIDE_OLDER_COMMENTS is not set
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 200 };
-
- mockGithub.rest.issues.listComments = vi.fn();
- mockGithub.graphql = vi.fn();
-
- const mockNewComment = {
- id: 5,
- html_url: "https://github.com/testowner/testrepo/issues/200#issuecomment-5",
- };
- mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- // Verify that listComments was NOT called (hide feature not enabled)
- expect(mockGithub.rest.issues.listComments).not.toHaveBeenCalled();
- expect(mockGithub.graphql).not.toHaveBeenCalled();
-
- // Verify that the new comment was created
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalled();
-
- // Clean up
- delete process.env.GITHUB_WORKFLOW;
- });
-
- it("should skip hiding when workflow-id is not available", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Comment without workflow-id",
- },
- ],
- });
- // Note: GITHUB_WORKFLOW is not set
- process.env.GH_AW_HIDE_OLDER_COMMENTS = "true";
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 300 };
-
- mockGithub.rest.issues.listComments = vi.fn();
- mockGithub.graphql = vi.fn();
-
- const mockNewComment = {
- id: 6,
- html_url: "https://github.com/testowner/testrepo/issues/300#issuecomment-6",
- };
- mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- // Verify that hiding was skipped (no workflow-id available)
- expect(mockGithub.rest.issues.listComments).not.toHaveBeenCalled();
- expect(mockGithub.graphql).not.toHaveBeenCalled();
-
- // Verify that the new comment was created
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalled();
-
- // Clean up
- delete process.env.GH_AW_HIDE_OLDER_COMMENTS;
- });
-
- it("should respect allowed-reasons when hiding comments", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "New comment with allowed reasons",
- },
- ],
- });
- process.env.GITHUB_WORKFLOW = "test-workflow-789";
- process.env.GH_AW_HIDE_OLDER_COMMENTS = "true";
- process.env.GH_AW_ALLOWED_REASONS = JSON.stringify(["OUTDATED", "RESOLVED"]);
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 400 };
-
- // Mock existing comments
- mockGithub.rest.issues.listComments = vi.fn().mockResolvedValue({
- data: [
- {
- id: 1,
- node_id: "IC_oldcomment1",
- body: "Old comment\n\n",
- },
- ],
- });
-
- // Mock the minimizeComment GraphQL mutation
- mockGithub.graphql = vi.fn().mockResolvedValue({
- minimizeComment: {
- minimizedComment: {
- isMinimized: true,
- },
- },
- });
-
- const mockNewComment = {
- id: 2,
- html_url: "https://github.com/testowner/testrepo/issues/400#issuecomment-2",
- };
- mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- // Verify that minimizeComment was called with OUTDATED (the default)
- expect(mockGithub.graphql).toHaveBeenCalledWith(
- expect.stringContaining("minimizeComment"),
- expect.objectContaining({
- nodeId: "IC_oldcomment1",
- classifier: "OUTDATED",
- })
- );
-
- // Clean up
- delete process.env.GITHUB_WORKFLOW;
- delete process.env.GH_AW_HIDE_OLDER_COMMENTS;
- delete process.env.GH_AW_ALLOWED_REASONS;
- });
-
- it("should skip hiding when reason is not in allowed-reasons", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "New comment with restricted reasons",
- },
- ],
- });
- process.env.GITHUB_WORKFLOW = "test-workflow-999";
- process.env.GH_AW_HIDE_OLDER_COMMENTS = "true";
- // Only allow SPAM, but default reason is OUTDATED
- process.env.GH_AW_ALLOWED_REASONS = JSON.stringify(["SPAM"]);
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 500 };
-
- mockGithub.rest.issues.listComments = vi.fn();
- mockGithub.graphql = vi.fn();
-
- const mockNewComment = {
- id: 3,
- html_url: "https://github.com/testowner/testrepo/issues/500#issuecomment-3",
- };
- mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- // Verify that hiding was skipped (OUTDATED not in allowed reasons)
- expect(mockGithub.rest.issues.listComments).not.toHaveBeenCalled();
- expect(mockGithub.graphql).not.toHaveBeenCalled();
-
- // Verify that the new comment was still created
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalled();
-
- // Clean up
- delete process.env.GITHUB_WORKFLOW;
- delete process.env.GH_AW_HIDE_OLDER_COMMENTS;
- delete process.env.GH_AW_ALLOWED_REASONS;
- });
-
- it("should support lowercase allowed-reasons", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "New comment with lowercase reasons",
- },
- ],
- });
- process.env.GITHUB_WORKFLOW = "test-workflow-lowercase";
- process.env.GH_AW_HIDE_OLDER_COMMENTS = "true";
- // Use lowercase reasons
- process.env.GH_AW_ALLOWED_REASONS = JSON.stringify(["outdated", "resolved"]);
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 600 };
-
- // Mock existing comments
- mockGithub.rest.issues.listComments = vi.fn().mockResolvedValue({
- data: [
- {
- id: 1,
- node_id: "IC_oldcomment1",
- body: "Old comment\n\n",
- },
- ],
- });
-
- // Mock the minimizeComment GraphQL mutation
- mockGithub.graphql = vi.fn().mockResolvedValue({
- minimizeComment: {
- minimizedComment: {
- isMinimized: true,
- },
- },
- });
-
- const mockNewComment = {
- id: 4,
- html_url: "https://github.com/testowner/testrepo/issues/600#issuecomment-4",
+ mockGithub = { rest: { issues: { createComment: vi.fn() } } },
+ mockContext = { eventName: "issues", runId: 12345, repo: { owner: "testowner", repo: "testrepo" }, payload: { issue: { number: 123 }, repository: { html_url: "https://github.com/testowner/testrepo" } } };
+((global.core = mockCore),
+ (global.github = mockGithub),
+ (global.context = mockContext),
+ describe("add_comment.cjs", () => {
+ let createCommentScript, tempFilePath;
+ const setAgentOutput = data => {
+ tempFilePath = path.join("/tmp", `test_agent_output_${Date.now()}_${Math.random().toString(36).slice(2)}.json`);
+ const content = "string" == typeof data ? data : JSON.stringify(data);
+ (fs.writeFileSync(tempFilePath, content), (process.env.GH_AW_AGENT_OUTPUT = tempFilePath));
};
- mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- // Verify that minimizeComment was called with OUTDATED (uppercase, normalized)
- expect(mockGithub.graphql).toHaveBeenCalledWith(
- expect.stringContaining("minimizeComment"),
- expect.objectContaining({
- nodeId: "IC_oldcomment1",
- classifier: "OUTDATED",
- })
- );
-
- // Clean up
- delete process.env.GITHUB_WORKFLOW;
- delete process.env.GH_AW_HIDE_OLDER_COMMENTS;
- delete process.env.GH_AW_ALLOWED_REASONS;
- });
-});
+ (beforeEach(() => {
+ (vi.clearAllMocks(), delete process.env.GH_AW_AGENT_OUTPUT, delete process.env.GITHUB_WORKFLOW, (global.context.eventName = "issues"), (global.context.payload.issue = { number: 123 }));
+ const scriptPath = path.join(process.cwd(), "add_comment.cjs");
+ createCommentScript = fs.readFileSync(scriptPath, "utf8");
+ }),
+ afterEach(() => {
+ tempFilePath && require("fs").existsSync(tempFilePath) && (require("fs").unlinkSync(tempFilePath), (tempFilePath = void 0));
+ }),
+ it("should skip when no agent output is provided", async () => {
+ (delete process.env.GH_AW_AGENT_OUTPUT,
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockCore.info).toHaveBeenCalledWith("No GH_AW_AGENT_OUTPUT environment variable found"),
+ expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled());
+ }),
+ it("should skip when agent output is empty", async () => {
+ (setAgentOutput(""), await eval(`(async () => { ${createCommentScript} })()`), expect(mockCore.info).toHaveBeenCalledWith("Agent output content is empty"), expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled());
+ }),
+ it("should skip when not in issue or PR context", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment content" }] }),
+ (global.context.eventName = "push"),
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockCore.info).toHaveBeenCalledWith('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'),
+ expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled());
+ }),
+ it("should create comment on issue successfully", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment content" }] }), (global.context.eventName = "issues"));
+ const mockComment = { id: 456, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }),
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({ owner: "testowner", repo: "testrepo", issue_number: 123, body: expect.stringContaining("Test comment content") }),
+ expect(mockCore.setOutput).toHaveBeenCalledWith("comment_id", 456),
+ expect(mockCore.setOutput).toHaveBeenCalledWith("comment_url", mockComment.html_url),
+ expect(mockCore.summary.addRaw).toHaveBeenCalled(),
+ expect(mockCore.summary.write).toHaveBeenCalled());
+ }),
+ it("should create comment on pull request successfully", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test PR comment content" }] }), (global.context.eventName = "pull_request"), (global.context.payload.pull_request = { number: 789 }), delete global.context.payload.issue);
+ const mockComment = { id: 789, html_url: "https://github.com/testowner/testrepo/issues/789#issuecomment-789" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }),
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({ owner: "testowner", repo: "testrepo", issue_number: 789, body: expect.stringContaining("Test PR comment content") }));
+ }),
+ it("should include run information in comment body", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test content" }] }), (global.context.eventName = "issues"), (global.context.payload.issue = { number: 123 }));
+ const mockComment = { id: 456, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }),
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(),
+ expect(mockGithub.rest.issues.createComment.mock.calls).toHaveLength(1));
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ (expect(callArgs.body).toContain("Test content"), expect(callArgs.body).toContain("This treasure was crafted by"), expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345"));
+ }),
+ it("should include workflow source in footer when GH_AW_WORKFLOW_SOURCE is provided", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test content with source" }] }),
+ (process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"),
+ (process.env.GH_AW_WORKFLOW_SOURCE = "githubnext/agentics/workflows/ci-doctor.md@v1.0.0"),
+ (process.env.GH_AW_WORKFLOW_SOURCE_URL = "https://github.com/githubnext/agentics/tree/v1.0.0/workflows/ci-doctor.md"),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 123 }));
+ const mockComment = { id: 456, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript} })()`), expect(mockGithub.rest.issues.createComment).toHaveBeenCalled());
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ (expect(callArgs.body).toContain("Test content with source"),
+ expect(callArgs.body).toContain("[🏴☠️ Test Workflow]"),
+ expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345"),
+ expect(callArgs.body).toContain("gh aw add githubnext/agentics/workflows/ci-doctor.md@v1.0.0"));
+ }),
+ it("should not include workflow source footer when GH_AW_WORKFLOW_SOURCE is not provided", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test content without source" }] }),
+ (process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"),
+ delete process.env.GH_AW_WORKFLOW_SOURCE,
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 123 }));
+ const mockComment = { id: 456, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript} })()`), expect(mockGithub.rest.issues.createComment).toHaveBeenCalled());
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ (expect(callArgs.body).toContain("Test content without source"), expect(callArgs.body).toContain("[🏴☠️ Test Workflow]"), expect(callArgs.body).not.toContain("gh aw add"));
+ }),
+ it("should use GITHUB_SERVER_URL when repository context is not available", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test content with custom server" }] }),
+ (process.env.GITHUB_SERVER_URL = "https://github.enterprise.com"),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 123 }),
+ delete global.context.payload.repository);
+ const mockComment = { id: 456, html_url: "https://github.enterprise.com/testowner/testrepo/issues/123#issuecomment-456" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript} })()`), expect(mockGithub.rest.issues.createComment).toHaveBeenCalled());
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ (expect(callArgs.body).toContain("Test content with custom server"),
+ expect(callArgs.body).toContain("https://github.enterprise.com/testowner/testrepo/actions/runs/12345"),
+ expect(callArgs.body).not.toContain("https://github.com/testowner/testrepo/actions/runs/12345"),
+ delete process.env.GITHUB_SERVER_URL);
+ }),
+ it("should fallback to https://github.com when GITHUB_SERVER_URL is not set and repository context is missing", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test content with fallback" }] }),
+ delete process.env.GITHUB_SERVER_URL,
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 123 }),
+ delete global.context.payload.repository);
+ const mockComment = { id: 456, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript} })()`), expect(mockGithub.rest.issues.createComment).toHaveBeenCalled());
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ (expect(callArgs.body).toContain("Test content with fallback"), expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345"));
+ }),
+ it("should include triggering issue number in footer when in issue context", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Comment from issue context" }] }), (process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"), (global.context.eventName = "issues"), (global.context.payload.issue = { number: 42 }));
+ const mockComment = { id: 789, html_url: "https://github.com/testowner/testrepo/issues/42#issuecomment-789" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript} })()`));
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ (expect(callArgs.body).toContain("Comment from issue context"), expect(callArgs.body).toContain("[🏴☠️ Test Workflow]"), expect(callArgs.body).toContain("#42"));
+ }),
+ it("should include triggering PR number in footer when in PR context", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Comment from PR context" }] }),
+ (process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"),
+ (global.context.eventName = "pull_request"),
+ delete global.context.payload.issue,
+ (global.context.payload.pull_request = { number: 123 }));
+ const mockComment = { id: 890, html_url: "https://github.com/testowner/testrepo/pull/123#issuecomment-890" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript} })()`));
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ (expect(callArgs.body).toContain("Comment from PR context"), expect(callArgs.body).toContain("[🏴☠️ Test Workflow]"), expect(callArgs.body).toContain("#123"), delete global.context.payload.pull_request);
+ }),
+ it("should use header level 4 for related items in comments", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment with related items" }] }),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 123 }),
+ (process.env.GH_AW_CREATED_ISSUE_URL = "https://github.com/testowner/testrepo/issues/456"),
+ (process.env.GH_AW_CREATED_ISSUE_NUMBER = "456"),
+ (process.env.GH_AW_CREATED_DISCUSSION_URL = "https://github.com/testowner/testrepo/discussions/789"),
+ (process.env.GH_AW_CREATED_DISCUSSION_NUMBER = "789"),
+ (process.env.GH_AW_CREATED_PULL_REQUEST_URL = "https://github.com/testowner/testrepo/pull/101"),
+ (process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER = "101"));
+ const mockComment = { id: 890, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-890" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript} })()`));
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ (expect(callArgs.body).toContain("#### Related Items"),
+ expect(callArgs.body).toMatch(/####\s+Related Items/),
+ expect(callArgs.body).not.toMatch(/^##\s+Related Items/m),
+ expect(callArgs.body).not.toMatch(/\*\*Related Items:\*\*/),
+ expect(callArgs.body).toContain("- Issue: [#456](https://github.com/testowner/testrepo/issues/456)"),
+ expect(callArgs.body).toContain("- Discussion: [#789](https://github.com/testowner/testrepo/discussions/789)"),
+ expect(callArgs.body).toContain("- Pull Request: [#101](https://github.com/testowner/testrepo/pull/101)"),
+ delete process.env.GH_AW_CREATED_ISSUE_URL,
+ delete process.env.GH_AW_CREATED_ISSUE_NUMBER,
+ delete process.env.GH_AW_CREATED_DISCUSSION_URL,
+ delete process.env.GH_AW_CREATED_DISCUSSION_NUMBER,
+ delete process.env.GH_AW_CREATED_PULL_REQUEST_URL,
+ delete process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER);
+ }),
+ it("should use header level 4 for related items in staged mode preview", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment in staged mode" }] }),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 123 }),
+ (process.env.GH_AW_SAFE_OUTPUTS_STAGED = "true"),
+ (process.env.GH_AW_CREATED_ISSUE_URL = "https://github.com/testowner/testrepo/issues/456"),
+ (process.env.GH_AW_CREATED_ISSUE_NUMBER = "456"),
+ (process.env.GH_AW_CREATED_DISCUSSION_URL = "https://github.com/testowner/testrepo/discussions/789"),
+ (process.env.GH_AW_CREATED_DISCUSSION_NUMBER = "789"),
+ (process.env.GH_AW_CREATED_PULL_REQUEST_URL = "https://github.com/testowner/testrepo/pull/101"),
+ (process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER = "101"),
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockCore.summary.addRaw).toHaveBeenCalled());
+ const summaryContent = mockCore.summary.addRaw.mock.calls[0][0];
+ (expect(summaryContent).toContain("#### Related Items"),
+ expect(summaryContent).toMatch(/####\s+Related Items/),
+ expect(summaryContent).not.toMatch(/^##\s+Related Items/m),
+ expect(summaryContent).not.toMatch(/\*\*Related Items:\*\*/),
+ expect(summaryContent).toContain("- Issue: [#456](https://github.com/testowner/testrepo/issues/456)"),
+ expect(summaryContent).toContain("- Discussion: [#789](https://github.com/testowner/testrepo/discussions/789)"),
+ expect(summaryContent).toContain("- Pull Request: [#101](https://github.com/testowner/testrepo/pull/101)"),
+ delete process.env.GH_AW_SAFE_OUTPUTS_STAGED,
+ delete process.env.GH_AW_CREATED_ISSUE_URL,
+ delete process.env.GH_AW_CREATED_ISSUE_NUMBER,
+ delete process.env.GH_AW_CREATED_DISCUSSION_URL,
+ delete process.env.GH_AW_CREATED_DISCUSSION_NUMBER,
+ delete process.env.GH_AW_CREATED_PULL_REQUEST_URL,
+ delete process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER);
+ }),
+ it("should create comment on discussion using GraphQL when in discussion_comment context", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test discussion comment" }] }),
+ (global.context.eventName = "discussion_comment"),
+ (global.context.payload.discussion = { number: 1993 }),
+ (global.context.payload.comment = { id: 12345, node_id: "DC_kwDOABcD1M4AaBbC" }),
+ delete global.context.payload.issue,
+ delete global.context.payload.pull_request);
+ const mockGraphqlResponse = vi.fn();
+ (mockGraphqlResponse
+ .mockResolvedValueOnce({ repository: { discussion: { id: "D_kwDOPc1QR84BpqRs", url: "https://github.com/testowner/testrepo/discussions/1993" } } })
+ .mockResolvedValueOnce({
+ addDiscussionComment: { comment: { id: "DC_kwDOPc1QR84BpqRt", body: "Test discussion comment", createdAt: "2025-10-19T22:00:00Z", url: "https://github.com/testowner/testrepo/discussions/1993#discussioncomment-123" } },
+ }),
+ (global.github.graphql = mockGraphqlResponse),
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockGraphqlResponse).toHaveBeenCalledTimes(2),
+ expect(mockGraphqlResponse.mock.calls[0][0]).toContain("query"),
+ expect(mockGraphqlResponse.mock.calls[0][0]).toContain("discussion(number: $num)"),
+ expect(mockGraphqlResponse.mock.calls[0][1]).toEqual({ owner: "testowner", repo: "testrepo", num: 1993 }),
+ expect(mockGraphqlResponse.mock.calls[1][0]).toContain("mutation"),
+ expect(mockGraphqlResponse.mock.calls[1][0]).toContain("addDiscussionComment"),
+ expect(mockGraphqlResponse.mock.calls[1][0]).toContain("replyToId"),
+ expect(mockGraphqlResponse.mock.calls[1][1].body).toContain("Test discussion comment"),
+ expect(mockGraphqlResponse.mock.calls[1][1].replyToId).toBe("DC_kwDOABcD1M4AaBbC"),
+ expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(),
+ expect(mockCore.setOutput).toHaveBeenCalledWith("comment_id", "DC_kwDOPc1QR84BpqRt"),
+ expect(mockCore.setOutput).toHaveBeenCalledWith("comment_url", "https://github.com/testowner/testrepo/discussions/1993#discussioncomment-123"),
+ delete global.github.graphql,
+ delete global.context.payload.discussion,
+ delete global.context.payload.comment);
+ }),
+ it("should create comment on discussion using GraphQL when GITHUB_AW_COMMENT_DISCUSSION is true (explicit discussion mode)", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test explicit discussion comment", item_number: 2001 }] }),
+ (process.env.GH_AW_COMMENT_TARGET = "*"),
+ (process.env.GITHUB_AW_COMMENT_DISCUSSION = "true"),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 123 }),
+ delete global.context.payload.discussion,
+ delete global.context.payload.pull_request);
+ const mockGraphqlResponse = vi.fn();
+ (mockGraphqlResponse
+ .mockResolvedValueOnce({ repository: { discussion: { id: "D_kwDOPc1QR84BpqRu", url: "https://github.com/testowner/testrepo/discussions/2001" } } })
+ .mockResolvedValueOnce({
+ addDiscussionComment: { comment: { id: "DC_kwDOPc1QR84BpqRv", body: "Test explicit discussion comment", createdAt: "2025-10-22T12:00:00Z", url: "https://github.com/testowner/testrepo/discussions/2001#discussioncomment-456" } },
+ }),
+ (global.github.graphql = mockGraphqlResponse),
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockGraphqlResponse).toHaveBeenCalledTimes(2),
+ expect(mockGraphqlResponse.mock.calls[0][0]).toContain("query"),
+ expect(mockGraphqlResponse.mock.calls[0][0]).toContain("discussion(number: $num)"),
+ expect(mockGraphqlResponse.mock.calls[0][1]).toEqual({ owner: "testowner", repo: "testrepo", num: 2001 }),
+ expect(mockGraphqlResponse.mock.calls[1][0]).toContain("mutation"),
+ expect(mockGraphqlResponse.mock.calls[1][0]).toContain("addDiscussionComment"),
+ expect(mockGraphqlResponse.mock.calls[1][1].body).toContain("Test explicit discussion comment"),
+ expect(mockGraphqlResponse.mock.calls[1][1].replyToId).toBeUndefined(),
+ expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(),
+ expect(mockCore.setOutput).toHaveBeenCalledWith("comment_id", "DC_kwDOPc1QR84BpqRv"),
+ expect(mockCore.setOutput).toHaveBeenCalledWith("comment_url", "https://github.com/testowner/testrepo/discussions/2001#discussioncomment-456"),
+ expect(mockCore.info).toHaveBeenCalledWith("Creating comment on discussion #2001"),
+ delete process.env.GH_AW_COMMENT_TARGET,
+ delete process.env.GITHUB_AW_COMMENT_DISCUSSION,
+ delete global.github.graphql);
+ }),
+ it("should replace temporary ID references in comment body using the temporary ID map", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "This comment references issue #aw_aabbccdd1122 which was created earlier." }] }),
+ (process.env.GH_AW_TEMPORARY_ID_MAP = JSON.stringify({ aw_aabbccdd1122: 456 })),
+ mockGithub.rest.issues.createComment.mockResolvedValue({ data: { id: 99999, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-99999" } }),
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith(expect.objectContaining({ body: expect.stringContaining("#456") })),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith(expect.objectContaining({ body: expect.not.stringContaining("#aw_aabbccdd1122") })),
+ delete process.env.GH_AW_TEMPORARY_ID_MAP);
+ }),
+ it("should load temporary ID map and log the count", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment" }] }),
+ (process.env.GH_AW_TEMPORARY_ID_MAP = JSON.stringify({ aw_abc123: 100, aw_def456: 200 })),
+ mockGithub.rest.issues.createComment.mockResolvedValue({ data: { id: 99999, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-99999" } }),
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockCore.info).toHaveBeenCalledWith("Loaded temporary ID map with 2 entries"),
+ delete process.env.GH_AW_TEMPORARY_ID_MAP);
+ }),
+ it("should handle empty temporary ID map gracefully", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Comment with #aw_000000000000 that won't be resolved" }] }),
+ (process.env.GH_AW_TEMPORARY_ID_MAP = "{}"),
+ mockGithub.rest.issues.createComment.mockResolvedValue({ data: { id: 99999, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-99999" } }),
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith(expect.objectContaining({ body: expect.stringContaining("#aw_000000000000") })),
+ delete process.env.GH_AW_TEMPORARY_ID_MAP);
+ }),
+ it("should use custom footer message when GH_AW_SAFE_OUTPUT_MESSAGES is configured", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment with custom footer" }] }),
+ (process.env.GH_AW_WORKFLOW_NAME = "Custom Workflow"),
+ (process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ footer: "> Custom AI footer by [{workflow_name}]({run_url})", footerInstall: "> Custom install: `gh aw add {workflow_source}`" })),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 456 }));
+ const mockComment = { id: 999, html_url: "https://github.com/testowner/testrepo/issues/456#issuecomment-999" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript} })()`));
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ (expect(callArgs.body).toContain("Test comment with custom footer"),
+ expect(callArgs.body).toContain("Custom AI footer by [Custom Workflow]"),
+ expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345"),
+ expect(callArgs.body).not.toContain("Ahoy!"),
+ expect(callArgs.body).not.toContain("treasure was crafted"),
+ delete process.env.GH_AW_SAFE_OUTPUT_MESSAGES);
+ }),
+ it("should use custom footer with install instructions when workflow source is provided", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment with custom footer and install" }] }),
+ (process.env.GH_AW_WORKFLOW_NAME = "Custom Workflow"),
+ (process.env.GH_AW_WORKFLOW_SOURCE = "owner/repo/workflow.md@main"),
+ (process.env.GH_AW_WORKFLOW_SOURCE_URL = "https://github.com/owner/repo"),
+ (process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ footer: "> Generated by [{workflow_name}]({run_url})", footerInstall: "> Install: `gh aw add {workflow_source}`" })),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 789 }));
+ const mockComment = { id: 1001, html_url: "https://github.com/testowner/testrepo/issues/789#issuecomment-1001" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript} })()`));
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ (expect(callArgs.body).toContain("Test comment with custom footer and install"),
+ expect(callArgs.body).toContain("Generated by [Custom Workflow]"),
+ expect(callArgs.body).toContain("Install: `gh aw add owner/repo/workflow.md@main`"),
+ expect(callArgs.body).not.toContain("Ahoy!"),
+ expect(callArgs.body).not.toContain("plunder this workflow"),
+ delete process.env.GH_AW_SAFE_OUTPUT_MESSAGES,
+ delete process.env.GH_AW_WORKFLOW_SOURCE,
+ delete process.env.GH_AW_WORKFLOW_SOURCE_URL);
+ }),
+ it("should hide older comments when hide-older-comments is enabled", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "New comment from workflow" }] }),
+ (process.env.GITHUB_WORKFLOW = "test-workflow-123"),
+ (process.env.GH_AW_HIDE_OLDER_COMMENTS = "true"),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 100 }),
+ (mockGithub.rest.issues.listComments = vi.fn().mockResolvedValue({
+ data: [
+ { id: 1, node_id: "IC_oldcomment1", body: "Old comment 1\n\n\x3c!-- workflow-id: test-workflow-123 --\x3e" },
+ { id: 2, node_id: "IC_oldcomment2", body: "Old comment 2\n\n\x3c!-- workflow-id: test-workflow-123 --\x3e" },
+ { id: 3, node_id: "IC_othercomment", body: "Comment from different workflow" },
+ ],
+ })),
+ (mockGithub.graphql = vi.fn().mockResolvedValue({ minimizeComment: { minimizedComment: { isMinimized: !0 } } })));
+ const mockNewComment = { id: 4, html_url: "https://github.com/testowner/testrepo/issues/100#issuecomment-4" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }),
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockGithub.rest.issues.listComments).toHaveBeenCalledWith({ owner: "testowner", repo: "testrepo", issue_number: 100, per_page: 100, page: 1 }),
+ expect(mockGithub.graphql).toHaveBeenCalledTimes(2),
+ expect(mockGithub.graphql).toHaveBeenCalledWith(expect.stringContaining("minimizeComment"), expect.objectContaining({ nodeId: "IC_oldcomment1", classifier: "OUTDATED" })),
+ expect(mockGithub.graphql).toHaveBeenCalledWith(expect.stringContaining("minimizeComment"), expect.objectContaining({ nodeId: "IC_oldcomment2", classifier: "OUTDATED" })),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith(expect.objectContaining({ owner: "testowner", repo: "testrepo", issue_number: 100, body: expect.stringContaining("New comment from workflow") })),
+ delete process.env.GITHUB_WORKFLOW,
+ delete process.env.GH_AW_HIDE_OLDER_COMMENTS);
+ }),
+ it("should not hide comments when hide-older-comments is not enabled", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "New comment without hiding" }] }),
+ (process.env.GITHUB_WORKFLOW = "test-workflow-456"),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 200 }),
+ (mockGithub.rest.issues.listComments = vi.fn()),
+ (mockGithub.graphql = vi.fn()));
+ const mockNewComment = { id: 5, html_url: "https://github.com/testowner/testrepo/issues/200#issuecomment-5" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }),
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockGithub.rest.issues.listComments).not.toHaveBeenCalled(),
+ expect(mockGithub.graphql).not.toHaveBeenCalled(),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(),
+ delete process.env.GITHUB_WORKFLOW);
+ }),
+ it("should skip hiding when workflow-id is not available", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Comment without workflow-id" }] }),
+ (process.env.GH_AW_HIDE_OLDER_COMMENTS = "true"),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 300 }),
+ (mockGithub.rest.issues.listComments = vi.fn()),
+ (mockGithub.graphql = vi.fn()));
+ const mockNewComment = { id: 6, html_url: "https://github.com/testowner/testrepo/issues/300#issuecomment-6" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }),
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockGithub.rest.issues.listComments).not.toHaveBeenCalled(),
+ expect(mockGithub.graphql).not.toHaveBeenCalled(),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(),
+ delete process.env.GH_AW_HIDE_OLDER_COMMENTS);
+ }),
+ it("should respect allowed-reasons when hiding comments", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "New comment with allowed reasons" }] }),
+ (process.env.GITHUB_WORKFLOW = "test-workflow-789"),
+ (process.env.GH_AW_HIDE_OLDER_COMMENTS = "true"),
+ (process.env.GH_AW_ALLOWED_REASONS = JSON.stringify(["OUTDATED", "RESOLVED"])),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 400 }),
+ (mockGithub.rest.issues.listComments = vi.fn().mockResolvedValue({ data: [{ id: 1, node_id: "IC_oldcomment1", body: "Old comment\n\n\x3c!-- workflow-id: test-workflow-789 --\x3e" }] })),
+ (mockGithub.graphql = vi.fn().mockResolvedValue({ minimizeComment: { minimizedComment: { isMinimized: !0 } } })));
+ const mockNewComment = { id: 2, html_url: "https://github.com/testowner/testrepo/issues/400#issuecomment-2" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }),
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockGithub.graphql).toHaveBeenCalledWith(expect.stringContaining("minimizeComment"), expect.objectContaining({ nodeId: "IC_oldcomment1", classifier: "OUTDATED" })),
+ delete process.env.GITHUB_WORKFLOW,
+ delete process.env.GH_AW_HIDE_OLDER_COMMENTS,
+ delete process.env.GH_AW_ALLOWED_REASONS);
+ }),
+ it("should skip hiding when reason is not in allowed-reasons", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "New comment with restricted reasons" }] }),
+ (process.env.GITHUB_WORKFLOW = "test-workflow-999"),
+ (process.env.GH_AW_HIDE_OLDER_COMMENTS = "true"),
+ (process.env.GH_AW_ALLOWED_REASONS = JSON.stringify(["SPAM"])),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 500 }),
+ (mockGithub.rest.issues.listComments = vi.fn()),
+ (mockGithub.graphql = vi.fn()));
+ const mockNewComment = { id: 3, html_url: "https://github.com/testowner/testrepo/issues/500#issuecomment-3" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }),
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockGithub.rest.issues.listComments).not.toHaveBeenCalled(),
+ expect(mockGithub.graphql).not.toHaveBeenCalled(),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(),
+ delete process.env.GITHUB_WORKFLOW,
+ delete process.env.GH_AW_HIDE_OLDER_COMMENTS,
+ delete process.env.GH_AW_ALLOWED_REASONS);
+ }),
+ it("should support lowercase allowed-reasons", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "New comment with lowercase reasons" }] }),
+ (process.env.GITHUB_WORKFLOW = "test-workflow-lowercase"),
+ (process.env.GH_AW_HIDE_OLDER_COMMENTS = "true"),
+ (process.env.GH_AW_ALLOWED_REASONS = JSON.stringify(["outdated", "resolved"])),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 600 }),
+ (mockGithub.rest.issues.listComments = vi.fn().mockResolvedValue({ data: [{ id: 1, node_id: "IC_oldcomment1", body: "Old comment\n\n\x3c!-- workflow-id: test-workflow-lowercase --\x3e" }] })),
+ (mockGithub.graphql = vi.fn().mockResolvedValue({ minimizeComment: { minimizedComment: { isMinimized: !0 } } })));
+ const mockNewComment = { id: 4, html_url: "https://github.com/testowner/testrepo/issues/600#issuecomment-4" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }),
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockGithub.graphql).toHaveBeenCalledWith(expect.stringContaining("minimizeComment"), expect.objectContaining({ nodeId: "IC_oldcomment1", classifier: "OUTDATED" })),
+ delete process.env.GITHUB_WORKFLOW,
+ delete process.env.GH_AW_HIDE_OLDER_COMMENTS,
+ delete process.env.GH_AW_ALLOWED_REASONS);
+ }));
+ }));
diff --git a/pkg/workflow/js/add_copilot_reviewer.cjs b/pkg/workflow/js/add_copilot_reviewer.cjs
index 40281e4bd8d..d7bced581f9 100644
--- a/pkg/workflow/js/add_copilot_reviewer.cjs
+++ b/pkg/workflow/js/add_copilot_reviewer.cjs
@@ -1,63 +1,21 @@
-// @ts-check
-///
-
-/**
- * Add Copilot as a reviewer to a pull request.
- *
- * This script is used to add the GitHub Copilot pull request reviewer bot
- * to a pull request. It uses the `github` object from actions/github-script
- * instead of the `gh api` CLI command.
- *
- * Environment variables:
- * - PR_NUMBER: The pull request number to add the reviewer to
- */
-
-// GitHub Copilot reviewer bot username
const COPILOT_REVIEWER_BOT = "copilot-pull-request-reviewer[bot]";
-
async function main() {
- // Validate required environment variables
const prNumberStr = process.env.PR_NUMBER;
-
- if (!prNumberStr || prNumberStr.trim() === "") {
- core.setFailed("PR_NUMBER environment variable is required but not set");
- return;
- }
-
+ if (!prNumberStr || "" === prNumberStr.trim()) return void core.setFailed("PR_NUMBER environment variable is required but not set");
const prNumber = parseInt(prNumberStr.trim(), 10);
- if (isNaN(prNumber) || prNumber <= 0) {
- core.setFailed(`Invalid PR_NUMBER: ${prNumberStr}. Must be a positive integer.`);
- return;
- }
-
- core.info(`Adding Copilot as reviewer to PR #${prNumber}`);
-
- try {
- await github.rest.pulls.requestReviewers({
- owner: context.repo.owner,
- repo: context.repo.repo,
- pull_number: prNumber,
- reviewers: [COPILOT_REVIEWER_BOT],
- });
-
- core.info(`Successfully added Copilot as reviewer to PR #${prNumber}`);
-
- await core.summary
- .addRaw(
- `
-## Copilot Reviewer Added
-
-Successfully added Copilot as a reviewer to PR #${prNumber}.
-`
- )
- .write();
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error);
- core.error(`Failed to add Copilot as reviewer: ${errorMessage}`);
- core.setFailed(`Failed to add Copilot as reviewer to PR #${prNumber}: ${errorMessage}`);
+ if (isNaN(prNumber) || prNumber <= 0) core.setFailed(`Invalid PR_NUMBER: ${prNumberStr}. Must be a positive integer.`);
+ else {
+ core.info(`Adding Copilot as reviewer to PR #${prNumber}`);
+ try {
+ (await github.rest.pulls.requestReviewers({ owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber, reviewers: [COPILOT_REVIEWER_BOT] }),
+ core.info(`Successfully added Copilot as reviewer to PR #${prNumber}`),
+ await core.summary.addRaw(`\n## Copilot Reviewer Added\n\nSuccessfully added Copilot as a reviewer to PR #${prNumber}.\n`).write());
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ (core.error(`Failed to add Copilot as reviewer: ${errorMessage}`), core.setFailed(`Failed to add Copilot as reviewer to PR #${prNumber}: ${errorMessage}`));
+ }
}
}
-
main().catch(error => {
core.setFailed(error instanceof Error ? error.message : String(error));
});
diff --git a/pkg/workflow/js/add_copilot_reviewer.test.cjs b/pkg/workflow/js/add_copilot_reviewer.test.cjs
index d2ef7af9288..464deea5d93 100644
--- a/pkg/workflow/js/add_copilot_reviewer.test.cjs
+++ b/pkg/workflow/js/add_copilot_reviewer.test.cjs
@@ -1,151 +1,62 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
-
-// Mock the global objects that GitHub Actions provides
-const mockCore = {
- debug: vi.fn(),
- info: vi.fn(),
- notice: vi.fn(),
- warning: vi.fn(),
- error: vi.fn(),
- setFailed: vi.fn(),
- setOutput: vi.fn(),
- summary: {
- addRaw: vi.fn().mockReturnThis(),
- write: vi.fn().mockResolvedValue(),
- },
-};
-
-const mockGithub = {
- rest: {
- pulls: {
- requestReviewers: vi.fn().mockResolvedValue({}),
- },
- },
-};
-
-const mockContext = {
- eventName: "pull_request",
- repo: {
- owner: "testowner",
- repo: "testrepo",
- },
- payload: {
- pull_request: {
- number: 123,
- },
- },
-};
-
-// Set up global mocks before importing the module
-global.core = mockCore;
-global.github = mockGithub;
-global.context = mockContext;
-
-describe("add_copilot_reviewer", () => {
- beforeEach(() => {
- // Reset all mocks before each test
- vi.clearAllMocks();
- vi.resetModules(); // Reset module cache to allow fresh imports
-
- // Clear environment variables
- delete process.env.PR_NUMBER;
-
- // Reset context to default
- global.context = {
- eventName: "pull_request",
- repo: {
- owner: "testowner",
- repo: "testrepo",
- },
- payload: {
- pull_request: {
- number: 123,
- },
- },
- };
- });
-
- it("should fail when PR_NUMBER is not set", async () => {
- delete process.env.PR_NUMBER;
-
- await import("./add_copilot_reviewer.cjs");
-
- expect(mockCore.setFailed).toHaveBeenCalledWith("PR_NUMBER environment variable is required but not set");
- expect(mockGithub.rest.pulls.requestReviewers).not.toHaveBeenCalled();
- });
-
- it("should fail when PR_NUMBER is empty", async () => {
- process.env.PR_NUMBER = " ";
-
- await import("./add_copilot_reviewer.cjs");
-
- expect(mockCore.setFailed).toHaveBeenCalledWith("PR_NUMBER environment variable is required but not set");
- expect(mockGithub.rest.pulls.requestReviewers).not.toHaveBeenCalled();
- });
-
- it("should fail when PR_NUMBER is not a valid number", async () => {
- process.env.PR_NUMBER = "not-a-number";
-
- await import("./add_copilot_reviewer.cjs");
-
- expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Invalid PR_NUMBER"));
- expect(mockGithub.rest.pulls.requestReviewers).not.toHaveBeenCalled();
- });
-
- it("should fail when PR_NUMBER is zero", async () => {
- process.env.PR_NUMBER = "0";
-
- await import("./add_copilot_reviewer.cjs");
-
- expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Invalid PR_NUMBER"));
- expect(mockGithub.rest.pulls.requestReviewers).not.toHaveBeenCalled();
- });
-
- it("should fail when PR_NUMBER is negative", async () => {
- process.env.PR_NUMBER = "-1";
-
- await import("./add_copilot_reviewer.cjs");
-
- expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Invalid PR_NUMBER"));
- expect(mockGithub.rest.pulls.requestReviewers).not.toHaveBeenCalled();
- });
-
- it("should add copilot as reviewer when PR_NUMBER is valid", async () => {
- process.env.PR_NUMBER = "456";
-
- await import("./add_copilot_reviewer.cjs");
-
- expect(mockGithub.rest.pulls.requestReviewers).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- pull_number: 456,
- reviewers: ["copilot-pull-request-reviewer[bot]"],
- });
- expect(mockCore.info).toHaveBeenCalledWith("Successfully added Copilot as reviewer to PR #456");
- expect(mockCore.summary.addRaw).toHaveBeenCalled();
- expect(mockCore.summary.write).toHaveBeenCalled();
- });
-
- it("should handle API errors gracefully", async () => {
- process.env.PR_NUMBER = "123";
- mockGithub.rest.pulls.requestReviewers.mockRejectedValueOnce(new Error("API Error"));
-
- await import("./add_copilot_reviewer.cjs");
-
- expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to add Copilot as reviewer"));
- expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Failed to add Copilot as reviewer"));
- });
-
- it("should trim whitespace from PR_NUMBER", async () => {
- process.env.PR_NUMBER = " 789 ";
-
- await import("./add_copilot_reviewer.cjs");
-
- expect(mockGithub.rest.pulls.requestReviewers).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- pull_number: 789,
- reviewers: ["copilot-pull-request-reviewer[bot]"],
- });
- });
-});
+const mockCore = { debug: vi.fn(), info: vi.fn(), notice: vi.fn(), warning: vi.fn(), error: vi.fn(), setFailed: vi.fn(), setOutput: vi.fn(), summary: { addRaw: vi.fn().mockReturnThis(), write: vi.fn().mockResolvedValue() } },
+ mockGithub = { rest: { pulls: { requestReviewers: vi.fn().mockResolvedValue({}) } } },
+ mockContext = { eventName: "pull_request", repo: { owner: "testowner", repo: "testrepo" }, payload: { pull_request: { number: 123 } } };
+((global.core = mockCore),
+ (global.github = mockGithub),
+ (global.context = mockContext),
+ describe("add_copilot_reviewer", () => {
+ (beforeEach(() => {
+ (vi.clearAllMocks(), vi.resetModules(), delete process.env.PR_NUMBER, (global.context = { eventName: "pull_request", repo: { owner: "testowner", repo: "testrepo" }, payload: { pull_request: { number: 123 } } }));
+ }),
+ it("should fail when PR_NUMBER is not set", async () => {
+ (delete process.env.PR_NUMBER,
+ await import("./add_copilot_reviewer.cjs"),
+ expect(mockCore.setFailed).toHaveBeenCalledWith("PR_NUMBER environment variable is required but not set"),
+ expect(mockGithub.rest.pulls.requestReviewers).not.toHaveBeenCalled());
+ }),
+ it("should fail when PR_NUMBER is empty", async () => {
+ ((process.env.PR_NUMBER = " "),
+ await import("./add_copilot_reviewer.cjs"),
+ expect(mockCore.setFailed).toHaveBeenCalledWith("PR_NUMBER environment variable is required but not set"),
+ expect(mockGithub.rest.pulls.requestReviewers).not.toHaveBeenCalled());
+ }),
+ it("should fail when PR_NUMBER is not a valid number", async () => {
+ ((process.env.PR_NUMBER = "not-a-number"),
+ await import("./add_copilot_reviewer.cjs"),
+ expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Invalid PR_NUMBER")),
+ expect(mockGithub.rest.pulls.requestReviewers).not.toHaveBeenCalled());
+ }),
+ it("should fail when PR_NUMBER is zero", async () => {
+ ((process.env.PR_NUMBER = "0"),
+ await import("./add_copilot_reviewer.cjs"),
+ expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Invalid PR_NUMBER")),
+ expect(mockGithub.rest.pulls.requestReviewers).not.toHaveBeenCalled());
+ }),
+ it("should fail when PR_NUMBER is negative", async () => {
+ ((process.env.PR_NUMBER = "-1"),
+ await import("./add_copilot_reviewer.cjs"),
+ expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Invalid PR_NUMBER")),
+ expect(mockGithub.rest.pulls.requestReviewers).not.toHaveBeenCalled());
+ }),
+ it("should add copilot as reviewer when PR_NUMBER is valid", async () => {
+ ((process.env.PR_NUMBER = "456"),
+ await import("./add_copilot_reviewer.cjs"),
+ expect(mockGithub.rest.pulls.requestReviewers).toHaveBeenCalledWith({ owner: "testowner", repo: "testrepo", pull_number: 456, reviewers: ["copilot-pull-request-reviewer[bot]"] }),
+ expect(mockCore.info).toHaveBeenCalledWith("Successfully added Copilot as reviewer to PR #456"),
+ expect(mockCore.summary.addRaw).toHaveBeenCalled(),
+ expect(mockCore.summary.write).toHaveBeenCalled());
+ }),
+ it("should handle API errors gracefully", async () => {
+ ((process.env.PR_NUMBER = "123"),
+ mockGithub.rest.pulls.requestReviewers.mockRejectedValueOnce(new Error("API Error")),
+ await import("./add_copilot_reviewer.cjs"),
+ expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to add Copilot as reviewer")),
+ expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Failed to add Copilot as reviewer")));
+ }),
+ it("should trim whitespace from PR_NUMBER", async () => {
+ ((process.env.PR_NUMBER = " 789 "),
+ await import("./add_copilot_reviewer.cjs"),
+ expect(mockGithub.rest.pulls.requestReviewers).toHaveBeenCalledWith({ owner: "testowner", repo: "testrepo", pull_number: 789, reviewers: ["copilot-pull-request-reviewer[bot]"] }));
+ }));
+ }));
diff --git a/pkg/workflow/js/add_labels.test.cjs b/pkg/workflow/js/add_labels.test.cjs
index 066f535208b..f750206e14f 100644
--- a/pkg/workflow/js/add_labels.test.cjs
+++ b/pkg/workflow/js/add_labels.test.cjs
@@ -1,1088 +1,436 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import fs from "fs";
import path from "path";
-
-// Mock the global objects that GitHub Actions provides
const mockCore = {
- // Core logging functions
- debug: vi.fn(),
- info: vi.fn(),
- notice: vi.fn(),
- warning: vi.fn(),
- error: vi.fn(),
-
- // Core workflow functions
- setFailed: vi.fn(),
- setOutput: vi.fn(),
- exportVariable: vi.fn(),
- setSecret: vi.fn(),
-
- // Input/state functions (less commonly used but included for completeness)
- getInput: vi.fn(),
- getBooleanInput: vi.fn(),
- getMultilineInput: vi.fn(),
- getState: vi.fn(),
- saveState: vi.fn(),
-
- // Group functions
- startGroup: vi.fn(),
- endGroup: vi.fn(),
- group: vi.fn(),
-
- // Other utility functions
- addPath: vi.fn(),
- setCommandEcho: vi.fn(),
- isDebug: vi.fn().mockReturnValue(false),
- getIDToken: vi.fn(),
- toPlatformPath: vi.fn(),
- toPosixPath: vi.fn(),
- toWin32Path: vi.fn(),
-
- // Summary object with chainable methods
- summary: {
- addRaw: vi.fn().mockReturnThis(),
- write: vi.fn().mockResolvedValue(),
+ debug: vi.fn(),
+ info: vi.fn(),
+ notice: vi.fn(),
+ warning: vi.fn(),
+ error: vi.fn(),
+ setFailed: vi.fn(),
+ setOutput: vi.fn(),
+ exportVariable: vi.fn(),
+ setSecret: vi.fn(),
+ getInput: vi.fn(),
+ getBooleanInput: vi.fn(),
+ getMultilineInput: vi.fn(),
+ getState: vi.fn(),
+ saveState: vi.fn(),
+ startGroup: vi.fn(),
+ endGroup: vi.fn(),
+ group: vi.fn(),
+ addPath: vi.fn(),
+ setCommandEcho: vi.fn(),
+ isDebug: vi.fn().mockReturnValue(!1),
+ getIDToken: vi.fn(),
+ toPlatformPath: vi.fn(),
+ toPosixPath: vi.fn(),
+ toWin32Path: vi.fn(),
+ summary: { addRaw: vi.fn().mockReturnThis(), write: vi.fn().mockResolvedValue() },
},
-};
-
-const mockGithub = {
- rest: {
- issues: {
- addLabels: vi.fn(),
- },
- },
-};
-
-const mockContext = {
- eventName: "issues",
- repo: {
- owner: "testowner",
- repo: "testrepo",
- },
- payload: {
- issue: {
- number: 123,
- },
- },
-};
-
-// Set up global variables
-global.core = mockCore;
-global.github = mockGithub;
-global.context = mockContext;
-
-describe("add_labels.cjs", () => {
- let addLabelsScript;
-
- let tempFilePath;
-
- // Helper function to set agent output via file
- const setAgentOutput = data => {
- tempFilePath = path.join("/tmp", `test_agent_output_${Date.now()}_${Math.random().toString(36).slice(2)}.json`);
- const content = typeof data === "string" ? data : JSON.stringify(data);
- fs.writeFileSync(tempFilePath, content);
- process.env.GH_AW_AGENT_OUTPUT = tempFilePath;
- };
-
- beforeEach(() => {
- // Reset all mocks
- vi.clearAllMocks();
-
- // Reset environment variables
- delete process.env.GH_AW_AGENT_OUTPUT;
- delete process.env.GH_AW_LABELS_ALLOWED;
- delete process.env.GH_AW_LABELS_MAX_COUNT;
-
- // Reset context to default state
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 123 };
- delete global.context.payload.pull_request;
-
- // Read the script content
- const scriptPath = path.join(process.cwd(), "add_labels.cjs");
- addLabelsScript = fs.readFileSync(scriptPath, "utf8");
- });
-
- afterEach(() => {
- // Clean up temporary file
- if (tempFilePath && require("fs").existsSync(tempFilePath)) {
- require("fs").unlinkSync(tempFilePath);
- tempFilePath = undefined;
- }
- });
-
- describe("Environment variable validation", () => {
- it("should skip when no agent output is provided", async () => {
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- delete process.env.GH_AW_AGENT_OUTPUT;
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("No GH_AW_AGENT_OUTPUT environment variable found");
- expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled();
- });
-
- it("should skip when agent output is empty", async () => {
- setAgentOutput("");
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("Agent output content is empty");
- expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled();
- });
-
- it("should work when allowed labels are not provided (any labels allowed)", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement", "custom-label"],
- },
- ],
- });
- delete process.env.GH_AW_LABELS_ALLOWED;
- process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test label processing
-
- mockGithub.rest.issues.addLabels.mockResolvedValue({});
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("No label addition restrictions - any label additions are allowed");
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- labels: ["bug", "enhancement", "custom-label"],
- });
- });
-
- it("should work when allowed labels list is empty (any labels allowed)", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement", "custom-label"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = " ";
- process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test label processing
-
- mockGithub.rest.issues.addLabels.mockResolvedValue({});
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("No label addition restrictions - any label additions are allowed");
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- labels: ["bug", "enhancement", "custom-label"],
- });
- });
-
- it("should enforce allowed labels when restrictions are set", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement", "custom-label", "documentation"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test label filtering
-
- mockGithub.rest.issues.addLabels.mockResolvedValue({});
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith(`Allowed label additions: ${JSON.stringify(["bug", "enhancement"])}`);
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- labels: ["bug", "enhancement"], // 'custom-label' and 'documentation' filtered out
- });
- });
-
- it("should fail when max count is invalid", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- process.env.GH_AW_LABELS_MAX_COUNT = "invalid";
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.setFailed).toHaveBeenCalledWith("Invalid max value: invalid. Must be a positive integer");
- expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled();
- });
-
- it("should fail when max count is zero", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- process.env.GH_AW_LABELS_MAX_COUNT = "0";
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.setFailed).toHaveBeenCalledWith("Invalid max value: 0. Must be a positive integer");
- expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled();
- });
-
- it("should use default max count when not specified", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement", "feature", "documentation"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement,feature,documentation";
- delete process.env.GH_AW_LABELS_MAX_COUNT;
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("Max count: 1");
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- labels: ["bug"], // Only first 1 due to default max count
- });
- });
- });
-
- describe("Context validation", () => {
- it("should skip when not in issue or PR context (with default target)", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- global.context.eventName = "push";
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith('Target is "triggering" but not running in issue or pull request context, skipping label addition');
- expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled();
- });
-
- it("should work with issue_comment event", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- global.context.eventName = "issue_comment";
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalled();
- });
-
- it("should work with pull_request event", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- global.context.eventName = "pull_request";
- global.context.payload.pull_request = { number: 456 };
- delete global.context.payload.issue;
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 456,
- labels: ["bug"],
- });
- });
-
- it("should work with pull_request_review event", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- global.context.eventName = "pull_request_review";
- global.context.payload.pull_request = { number: 789 };
- delete global.context.payload.issue;
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 789,
- labels: ["bug"],
- });
- });
-
- it("should fail when issue context detected but no issue in payload", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- global.context.eventName = "issues";
- delete global.context.payload.issue;
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.setFailed).toHaveBeenCalledWith("Issue context detected but no issue found in payload");
- expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled();
- });
-
- it("should fail when PR context detected but no PR in payload", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- global.context.eventName = "pull_request";
- delete global.context.payload.issue;
- delete global.context.payload.pull_request;
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.setFailed).toHaveBeenCalledWith("Pull request context detected but no pull request found in payload");
- expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled();
- });
- });
-
- describe("Label parsing and validation", () => {
- it("should parse labels from agent output and add valid ones", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement", "documentation"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement,feature";
- process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test label filtering
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- labels: ["bug", "enhancement"], // 'documentation' not in allowed list
- });
-
- expect(mockCore.setOutput).toHaveBeenCalledWith("labels_added", "bug\nenhancement");
- expect(mockCore.summary.addRaw).toHaveBeenCalled();
- expect(mockCore.summary.write).toHaveBeenCalled();
- });
-
- it("should skip empty lines in agent output", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test label processing
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- labels: ["bug", "enhancement"],
- });
- });
-
- it("should fail when line starts with dash (removal indication)", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "-enhancement"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.setFailed).toHaveBeenCalledWith("Label removal is not permitted. Found line starting with '-': -enhancement");
- expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled();
- });
-
- it("should remove duplicate labels", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement", "bug", "enhancement"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test deduplication
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- labels: ["bug", "enhancement"], // Duplicates removed
- });
- });
-
- it("should enforce max count limit", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement", "feature", "documentation", "question"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement,feature,documentation,question";
- process.env.GH_AW_LABELS_MAX_COUNT = "2";
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("Too many labels (5), limiting to 2");
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- labels: ["bug", "enhancement"], // Only first 2
- });
- });
-
- it("should skip when no valid labels found", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["invalid", "another-invalid"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("No labels to add");
- expect(mockCore.setOutput).toHaveBeenCalledWith("labels_added", "");
- expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("No labels were added"));
- expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled();
- });
- });
-
- describe("GitHub API integration", () => {
- it("should successfully add labels to issue", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement,feature";
- process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test label addition
-
- mockGithub.rest.issues.addLabels.mockResolvedValue({});
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- labels: ["bug", "enhancement"],
- });
-
- expect(mockCore.info).toHaveBeenCalledWith("Successfully added 2 labels to issue #123");
- expect(mockCore.setOutput).toHaveBeenCalledWith("labels_added", "bug\nenhancement");
-
- const summaryCall = mockCore.summary.addRaw.mock.calls.find(call => call[0].includes("Successfully added 2 label(s) to issue #123"));
- expect(summaryCall).toBeDefined();
- expect(summaryCall[0]).toContain("- `bug`");
- expect(summaryCall[0]).toContain("- `enhancement`");
- });
-
- it("should successfully add labels to pull request", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- global.context.eventName = "pull_request";
- global.context.payload.pull_request = { number: 456 };
- delete global.context.payload.issue;
-
- mockGithub.rest.issues.addLabels.mockResolvedValue({});
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("Successfully added 1 labels to pull request #456");
-
- const summaryCall = mockCore.summary.addRaw.mock.calls.find(call => call[0].includes("Successfully added 1 label(s) to pull request #456"));
- expect(summaryCall).toBeDefined();
- });
-
- it("should handle GitHub API errors", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
-
- const apiError = new Error("Label does not exist");
- mockGithub.rest.issues.addLabels.mockRejectedValue(apiError);
-
- const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.error).toHaveBeenCalledWith("Failed to add labels: Label does not exist");
- expect(mockCore.setFailed).toHaveBeenCalledWith("Failed to add labels: Label does not exist");
- });
-
- it("should handle non-Error objects in catch block", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
-
- const stringError = "Something went wrong";
- mockGithub.rest.issues.addLabels.mockRejectedValue(stringError);
-
- const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.error).toHaveBeenCalledWith("Failed to add labels: Something went wrong");
- expect(mockCore.setFailed).toHaveBeenCalledWith("Failed to add labels: Something went wrong");
- });
- });
-
- describe("Output and logging", () => {
- it("should log agent output content length", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("Agent output content length: 64");
- });
-
- it("should log allowed labels and max count", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement,feature";
- process.env.GH_AW_LABELS_MAX_COUNT = "5";
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith(`Allowed label additions: ${JSON.stringify(["bug", "enhancement", "feature"])}`);
- expect(mockCore.info).toHaveBeenCalledWith("Max count: 5");
- });
-
- it("should log requested labels", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement", "invalid"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith(`Requested labels: ${JSON.stringify(["bug", "enhancement", "invalid"])}`);
- });
-
- it("should log final labels being added", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test logging
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith(`Adding 2 labels to issue #123: ${JSON.stringify(["bug", "enhancement"])}`);
- });
- });
-
- describe("Edge cases", () => {
- it("should handle whitespace in allowed labels", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = " bug , enhancement , feature ";
- process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test label processing
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith(`Allowed label additions: ${JSON.stringify(["bug", "enhancement", "feature"])}`);
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- labels: ["bug", "enhancement"],
- });
- });
-
- it("should handle empty entries in allowed labels", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,,enhancement,";
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith(`Allowed label additions: ${JSON.stringify(["bug", "enhancement"])}`);
- });
-
- it("should handle single label output", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
-
- mockGithub.rest.issues.addLabels.mockResolvedValue({});
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- labels: ["bug"],
- });
-
- expect(mockCore.setOutput).toHaveBeenCalledWith("labels_added", "bug");
- });
-
- it("should handle duplicate labels by removing duplicates", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement", "bug", "automation", "enhancement"],
- },
- ],
- });
- process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test deduplication
-
- mockGithub.rest.issues.addLabels.mockResolvedValue({});
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- labels: ["bug", "enhancement", "automation"],
- });
- });
-
- it("should sanitize labels by removing problematic characters", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug]]>");
- expect(result).toBe("(![CDATA[(script)alert('xss')(/script)]])");
- });
-
- it("should preserve inline formatting tags", () => {
- const input = "This is bold, italic, and bold too text.";
- const result = sanitizeContent(input);
- expect(result).toBe(input);
- });
-
- it("should preserve list structure tags", () => {
- const input = "
";
- const result = sanitizeContent(input);
- expect(result).toBe(input);
- });
-
- it("should preserve ordered list tags", () => {
- const input = "- First
- Second
";
- const result = sanitizeContent(input);
- expect(result).toBe(input);
- });
-
- it("should preserve blockquote tags", () => {
- const input = "This is a quote
";
- const result = sanitizeContent(input);
- expect(result).toBe(input);
- });
-
- it("should handle mixed allowed tags with formatting", () => {
- const input = "This is bold and italic text.
New line here.
";
- const result = sanitizeContent(input);
- expect(result).toBe(input);
- });
-
- it("should handle nested list structure", () => {
- const input = "";
- const result = sanitizeContent(input);
- expect(result).toBe(input);
- });
-
- it("should preserve details and summary tags", () => {
- const result1 = sanitizeContent("content ");
- expect(result1).toBe("content ");
-
- const result2 = sanitizeContent("content");
- expect(result2).toBe("content");
- });
-
- it("should convert removed tags that are no longer allowed", () => {
- // Tag that was previously allowed but is now removed: u
- const result3 = sanitizeContent("content");
- expect(result3).toBe("(u)content(/u)");
- });
-
- it("should preserve heading tags h1-h6", () => {
- const headings = ["h1", "h2", "h3", "h4", "h5", "h6"];
- headings.forEach(tag => {
- const input = `<${tag}>Heading${tag}>`;
- const result = sanitizeContent(input);
- expect(result).toBe(input);
- });
- });
-
- it("should preserve hr tag", () => {
- const result = sanitizeContent("Content before
Content after");
- expect(result).toBe("Content before
Content after");
- });
-
- it("should preserve pre tag", () => {
- const input = "Code block content
";
- const result = sanitizeContent(input);
- expect(result).toBe(input);
- });
-
- it("should preserve sub and sup tags", () => {
- const input1 = "H2O";
- const result1 = sanitizeContent(input1);
- expect(result1).toBe(input1);
-
- const input2 = "E=mc2";
- const result2 = sanitizeContent(input2);
- expect(result2).toBe(input2);
- });
-
- it("should preserve table structure tags", () => {
- const input = "";
- const result = sanitizeContent(input);
- expect(result).toBe(input);
- });
- });
-
- describe("ANSI escape sequence removal", () => {
- it("should remove ANSI color codes", () => {
- const result = sanitizeContent("\x1b[31mred text\x1b[0m");
- expect(result).toBe("red text");
- });
-
- it("should remove various ANSI codes", () => {
- const result = sanitizeContent("\x1b[1;32mBold Green\x1b[0m");
- expect(result).toBe("Bold Green");
- });
- });
-
- describe("control character removal", () => {
- it("should remove control characters", () => {
- const result = sanitizeContent("test\x00\x01\x02\x03content");
- expect(result).toBe("testcontent");
- });
-
- it("should preserve newlines and tabs", () => {
- const result = sanitizeContent("test\ncontent\twith\ttabs");
- expect(result).toBe("test\ncontent\twith\ttabs");
- });
-
- it("should remove DEL character", () => {
- const result = sanitizeContent("test\x7Fcontent");
- expect(result).toBe("testcontent");
- });
- });
-
- describe("URL protocol sanitization", () => {
- it("should allow HTTPS URLs", () => {
- const result = sanitizeContent("Visit https://github.com");
- expect(result).toBe("Visit https://github.com");
- });
-
- it("should redact HTTP URLs", () => {
- const result = sanitizeContent("Visit http://example.com");
- expect(result).toContain("(redacted)");
- expect(mockCore.info).toHaveBeenCalled();
- });
-
- it("should redact javascript: URLs", () => {
- const result = sanitizeContent("Click javascript:alert('xss')");
- expect(result).toContain("(redacted)");
- });
-
- it("should redact data: URLs", () => {
- const result = sanitizeContent("Image data:image/png;base64,abc123");
- expect(result).toContain("(redacted)");
- });
-
- it("should preserve file paths with colons", () => {
- const result = sanitizeContent("C:\\path\\to\\file");
- expect(result).toBe("C:\\path\\to\\file");
- });
-
- it("should preserve namespace patterns", () => {
- const result = sanitizeContent("std::vector::push_back");
- expect(result).toBe("std::vector::push_back");
- });
- });
-
- describe("URL domain filtering", () => {
- it("should allow default GitHub domains", () => {
- const urls = ["https://github.com/repo", "https://api.github.com/endpoint", "https://raw.githubusercontent.com/file", "https://example.github.io/page"];
-
- urls.forEach(url => {
- const result = sanitizeContent(`Visit ${url}`);
- expect(result).toBe(`Visit ${url}`);
- });
- });
-
- it("should redact disallowed domains", () => {
- const result = sanitizeContent("Visit https://evil.com/malicious");
- expect(result).toContain("(redacted)");
- expect(mockCore.info).toHaveBeenCalled();
- });
-
- it("should use custom allowed domains from environment", () => {
- process.env.GH_AW_ALLOWED_DOMAINS = "example.com,trusted.net";
- const result = sanitizeContent("Visit https://example.com/page");
- expect(result).toBe("Visit https://example.com/page");
- });
-
- it("should extract and allow GitHub Enterprise domains", () => {
- process.env.GITHUB_SERVER_URL = "https://github.company.com";
- const result = sanitizeContent("Visit https://github.company.com/repo");
- expect(result).toBe("Visit https://github.company.com/repo");
- });
-
- it("should allow subdomains of allowed domains", () => {
- const result = sanitizeContent("Visit https://subdomain.github.com/page");
- expect(result).toBe("Visit https://subdomain.github.com/page");
- });
-
- it("should log redacted domains", () => {
- sanitizeContent("Visit https://verylongdomainnamefortest.com/page");
- expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Redacted URL:"));
- expect(mockCore.debug).toHaveBeenCalledWith(expect.stringContaining("Redacted URL (full):"));
- });
- });
-
- describe("bot trigger neutralization", () => {
- it("should neutralize 'fixes #123' patterns", () => {
- const result = sanitizeContent("This fixes #123");
- expect(result).toBe("This `fixes #123`");
- });
-
- it("should neutralize 'closes #456' patterns", () => {
- const result = sanitizeContent("PR closes #456");
- expect(result).toBe("PR `closes #456`");
- });
-
- it("should neutralize 'resolves #789' patterns", () => {
- const result = sanitizeContent("This resolves #789");
- expect(result).toBe("This `resolves #789`");
- });
-
- it("should handle various bot trigger verbs", () => {
- const triggers = ["fix", "fixes", "close", "closes", "resolve", "resolves"];
- triggers.forEach(verb => {
- const result = sanitizeContent(`This ${verb} #123`);
- expect(result).toBe(`This \`${verb} #123\``);
- });
- });
-
- it("should neutralize alphanumeric issue references", () => {
- const result = sanitizeContent("fixes #abc123def");
- expect(result).toBe("`fixes #abc123def`");
- });
- });
-
- describe("content truncation", () => {
- it("should truncate content exceeding max length", () => {
- const longContent = "x".repeat(600000);
- const result = sanitizeContent(longContent);
-
- expect(result.length).toBeLessThan(longContent.length);
- expect(result).toContain("[Content truncated due to length]");
- });
-
- it("should truncate content exceeding max lines", () => {
- const manyLines = Array(70000).fill("line").join("\n");
- const result = sanitizeContent(manyLines);
-
- expect(result.split("\n").length).toBeLessThan(70000);
- expect(result).toContain("[Content truncated due to line count]");
- });
-
- it("should respect custom max length parameter", () => {
- const content = "x".repeat(200);
- const result = sanitizeContent(content, 100);
-
- expect(result.length).toBeLessThanOrEqual(100 + 50); // +50 for truncation message
- expect(result).toContain("[Content truncated");
- });
-
- it("should not truncate short content", () => {
- const shortContent = "This is a short message";
- const result = sanitizeContent(shortContent);
-
- expect(result).toBe(shortContent);
- expect(result).not.toContain("[Content truncated");
- });
- });
-
- describe("combined sanitization", () => {
- it("should apply all sanitizations correctly", () => {
- const input = `
-
- Hello @user, visit https://github.com
-
- This fixes #123
- \x1b[31mRed text\x1b[0m
- `;
-
- const result = sanitizeContent(input);
-
- expect(result).not.toContain("");
- expect(result).toContain("`@user`");
- expect(result).toContain("https://github.com");
- expect(result).not.toContain(""];
-
- maliciousInputs.forEach(input => {
- const result = sanitizeContent(input);
- expect(result).not.toContain("
{
- const input = "";
- const result = sanitizeContent(input);
-
- expect(result).toBe(input);
- });
- });
-
- describe("edge cases", () => {
- it("should handle empty string", () => {
- expect(sanitizeContent("")).toBe("");
- });
-
- it("should handle whitespace-only input", () => {
- expect(sanitizeContent(" \n\t ")).toBe("");
- });
-
- it("should handle content with only control characters", () => {
- const result = sanitizeContent("\x00\x01\x02\x03");
- expect(result).toBe("");
- });
-
- it("should handle content with multiple consecutive spaces", () => {
- const result = sanitizeContent("hello world");
- expect(result).toBe("hello world");
- });
-
- it("should handle Unicode characters", () => {
- const result = sanitizeContent("Hello 世界 🌍");
- expect(result).toBe("Hello 世界 🌍");
- });
-
- it("should handle URLs in query parameters", () => {
- const input = "https://github.com/redirect?url=https://github.com/target";
- const result = sanitizeContent(input);
-
- expect(result).toContain("github.com");
- expect(result).not.toContain("(redacted)");
- });
-
- it("should handle nested backticks", () => {
- const result = sanitizeContent("Already `@user` and @other");
- expect(result).toBe("Already `@user` and `@other`");
- });
- });
-
- describe("redacted domains collection", () => {
- let getRedactedDomains;
- let clearRedactedDomains;
- let writeRedactedDomainsLog;
- const fs = require("fs");
- const path = require("path");
-
- beforeEach(async () => {
- const module = await import("./sanitize_content.cjs");
- getRedactedDomains = module.getRedactedDomains;
- clearRedactedDomains = module.clearRedactedDomains;
- writeRedactedDomainsLog = module.writeRedactedDomainsLog;
- // Clear collected domains before each test
- clearRedactedDomains();
- });
-
- it("should collect redacted HTTPS domains", () => {
- sanitizeContent("Visit https://evil.com/malware");
- const domains = getRedactedDomains();
- expect(domains.length).toBe(1);
- expect(domains[0]).toBe("evil.com");
- });
-
- it("should collect redacted HTTP domains", () => {
- sanitizeContent("Visit http://example.com");
- const domains = getRedactedDomains();
- expect(domains.length).toBe(1);
- expect(domains[0]).toBe("example.com");
- });
-
- it("should collect redacted dangerous protocols", () => {
- sanitizeContent("Click javascript:alert(1)");
- const domains = getRedactedDomains();
- expect(domains.length).toBe(1);
- expect(domains[0]).toBe("javascript:");
- });
-
- it("should collect multiple redacted domains", () => {
- sanitizeContent("Visit https://bad1.com and http://bad2.com");
- const domains = getRedactedDomains();
- expect(domains.length).toBe(2);
- expect(domains).toContain("bad1.com");
- expect(domains).toContain("bad2.com");
- });
-
- it("should not collect allowed domains", () => {
- sanitizeContent("Visit https://github.com/repo");
- const domains = getRedactedDomains();
- expect(domains.length).toBe(0);
- });
-
- it("should clear collected domains", () => {
- sanitizeContent("Visit https://evil.com");
- expect(getRedactedDomains().length).toBe(1);
- clearRedactedDomains();
- expect(getRedactedDomains().length).toBe(0);
- });
-
- it("should return a copy of domains array", () => {
- sanitizeContent("Visit https://evil.com");
- const domains1 = getRedactedDomains();
- const domains2 = getRedactedDomains();
- expect(domains1).not.toBe(domains2);
- expect(domains1).toEqual(domains2);
- });
-
- describe("writeRedactedDomainsLog", () => {
- const testDir = "/tmp/gh-aw-test-redacted";
- const testFile = `${testDir}/redacted-urls.log`;
-
- afterEach(() => {
- // Clean up test files
- if (fs.existsSync(testFile)) {
- fs.unlinkSync(testFile);
- }
- if (fs.existsSync(testDir)) {
- fs.rmdirSync(testDir, { recursive: true });
- }
- });
-
- it("should return null when no domains collected", () => {
- const result = writeRedactedDomainsLog(testFile);
- expect(result).toBeNull();
- expect(fs.existsSync(testFile)).toBe(false);
- });
-
- it("should write domains to log file", () => {
- sanitizeContent("Visit https://evil.com/malware");
- const result = writeRedactedDomainsLog(testFile);
- expect(result).toBe(testFile);
- expect(fs.existsSync(testFile)).toBe(true);
-
- const content = fs.readFileSync(testFile, "utf8");
- expect(content).toContain("evil.com");
- // Should NOT contain the full URL, only the domain
- expect(content).not.toContain("https://evil.com/malware");
- });
-
- it("should write multiple domains to log file", () => {
- sanitizeContent("Visit https://bad1.com and http://bad2.com");
- writeRedactedDomainsLog(testFile);
-
- const content = fs.readFileSync(testFile, "utf8");
- const lines = content.trim().split("\n");
- expect(lines.length).toBe(2);
- expect(content).toContain("bad1.com");
- expect(content).toContain("bad2.com");
- });
-
- it("should create directory if it does not exist", () => {
- const nestedFile = `${testDir}/nested/redacted-urls.log`;
- sanitizeContent("Visit https://evil.com");
- writeRedactedDomainsLog(nestedFile);
- expect(fs.existsSync(nestedFile)).toBe(true);
-
- // Clean up nested directory
- fs.unlinkSync(nestedFile);
- fs.rmdirSync(path.dirname(nestedFile));
- });
-
- it("should use default path when not specified", () => {
- const defaultPath = "/tmp/gh-aw/redacted-urls.log";
- sanitizeContent("Visit https://evil.com");
- const result = writeRedactedDomainsLog();
- expect(result).toBe(defaultPath);
- expect(fs.existsSync(defaultPath)).toBe(true);
-
- // Clean up
- fs.unlinkSync(defaultPath);
- });
- });
- });
-});
+import{describe,it,expect,beforeEach,afterEach,vi}from"vitest";describe("sanitize_content.cjs",()=>{let mockCore,sanitizeContent;beforeEach(async()=>{mockCore={debug:vi.fn(),info:vi.fn(),warning:vi.fn(),error:vi.fn()},global.core=mockCore;const module=await import("./sanitize_content.cjs");sanitizeContent=module.sanitizeContent}),afterEach(()=>{delete global.core,delete process.env.GH_AW_ALLOWED_DOMAINS,delete process.env.GH_AW_COMMAND,delete process.env.GITHUB_SERVER_URL,delete process.env.GITHUB_API_URL}),describe("basic sanitization",()=>{it("should return empty string for null or undefined input",()=>{expect(sanitizeContent(null)).toBe(""),expect(sanitizeContent(void 0)).toBe("")}),it("should return empty string for non-string input",()=>{expect(sanitizeContent(123)).toBe(""),expect(sanitizeContent({})).toBe(""),expect(sanitizeContent([])).toBe("")}),it("should trim whitespace",()=>{expect(sanitizeContent(" hello world ")).toBe("hello world"),expect(sanitizeContent("\n\thello\n\t")).toBe("hello")}),it("should preserve normal text",()=>{expect(sanitizeContent("Hello, this is normal text.")).toBe("Hello, this is normal text.")})}),describe("command neutralization",()=>{beforeEach(()=>{process.env.GH_AW_COMMAND="bot"}),it("should neutralize command at start of text",()=>{const result=sanitizeContent("/bot do something");expect(result).toBe("`/bot` do something")}),it("should neutralize command after whitespace",()=>{const result=sanitizeContent(" /bot do something");expect(result).toBe("`/bot` do something")}),it("should not neutralize command in middle of text",()=>{const result=sanitizeContent("hello /bot world");expect(result).toBe("hello /bot world")}),it("should handle special regex characters in command name",()=>{process.env.GH_AW_COMMAND="my-bot+test";const result=sanitizeContent("/my-bot+test action");expect(result).toBe("`/my-bot+test` action")}),it("should not neutralize when no command is set",()=>{delete process.env.GH_AW_COMMAND;const result=sanitizeContent("/bot do something");expect(result).toBe("/bot do something")})}),describe("@mention neutralization",()=>{it("should neutralize @mentions",()=>{const result=sanitizeContent("Hello @user");expect(result).toBe("Hello `@user`")}),it("should neutralize @org/team mentions",()=>{const result=sanitizeContent("Hello @myorg/myteam");expect(result).toBe("Hello `@myorg/myteam`")}),it("should not neutralize @mentions already in backticks",()=>{const result=sanitizeContent("Already `@user` mentioned");expect(result).toBe("Already `@user` mentioned")}),it("should neutralize multiple @mentions",()=>{const result=sanitizeContent("@user1 and @user2 are here");expect(result).toBe("`@user1` and `@user2` are here")}),it("should not neutralize email addresses",()=>{const result=sanitizeContent("Contact email@example.com");expect(result).toBe("Contact email@example.com")})}),describe("@mention allowedAliases",()=>{it("should not neutralize mentions in allowedAliases list",()=>{const result=sanitizeContent("Hello @author",{allowedAliases:["author"]});expect(result).toBe("Hello @author")}),it("should neutralize mentions not in allowedAliases list",()=>{const result=sanitizeContent("Hello @other",{allowedAliases:["author"]});expect(result).toBe("Hello `@other`")}),it("should handle multiple mentions with some allowed",()=>{const result=sanitizeContent("Hello @author and @other",{allowedAliases:["author"]});expect(result).toBe("Hello @author and `@other`")}),it("should handle case-insensitive matching for allowedAliases",()=>{const result=sanitizeContent("Hello @Author",{allowedAliases:["author"]});expect(result).toBe("Hello @Author")}),it("should handle multiple allowed aliases",()=>{const result=sanitizeContent("Hello @user1 and @user2 and @other",{allowedAliases:["user1","user2"]});expect(result).toBe("Hello @user1 and @user2 and `@other`")}),it("should work with options object containing both maxLength and allowedAliases",()=>{const result=sanitizeContent("Hello @author and @other",{maxLength:524288,allowedAliases:["author"]});expect(result).toBe("Hello @author and `@other`")}),it("should handle empty allowedAliases array",()=>{const result=sanitizeContent("Hello @user",{allowedAliases:[]});expect(result).toBe("Hello `@user`")}),it("should not neutralize org/team mentions in allowedAliases",()=>{const result=sanitizeContent("Hello @myorg/myteam",{allowedAliases:["myorg/myteam"]});expect(result).toBe("Hello @myorg/myteam")}),it("should preserve backward compatibility with numeric maxLength parameter",()=>{const result=sanitizeContent("Hello @user",524288);expect(result).toBe("Hello `@user`")}),it("should log escaped mentions for debugging",()=>{const result=sanitizeContent("Hello @user1 and @user2",{allowedAliases:["user1"]});expect(result).toBe("Hello @user1 and `@user2`"),expect(mockCore.info).toHaveBeenCalledWith("Escaped mention: @user2 (not in allowed list)")}),it("should log multiple escaped mentions",()=>{const result=sanitizeContent("@user1 @user2 @user3",{allowedAliases:["user1"]});expect(result).toBe("@user1 `@user2` `@user3`"),expect(mockCore.info).toHaveBeenCalledWith("Escaped mention: @user2 (not in allowed list)"),expect(mockCore.info).toHaveBeenCalledWith("Escaped mention: @user3 (not in allowed list)")}),it("should not log when all mentions are allowed",()=>{const result=sanitizeContent("Hello @user1 and @user2",{allowedAliases:["user1","user2"]});expect(result).toBe("Hello @user1 and @user2");const escapedMentionCalls=mockCore.info.mock.calls.filter(call=>call[0].includes("Escaped mention"));expect(escapedMentionCalls).toHaveLength(0)})}),describe("XML comments removal",()=>{it("should remove XML comments",()=>{const result=sanitizeContent("Hello \x3c!-- comment --\x3e world");expect(result).toBe("Hello world")}),it("should remove malformed XML comments",()=>{const result=sanitizeContent("Hello \x3c!--! comment --!> world");expect(result).toBe("Hello world")}),it("should remove multiline XML comments",()=>{const result=sanitizeContent("Hello \x3c!-- multi\nline\ncomment --\x3e world");expect(result).toBe("Hello world")})}),describe("XML/HTML tag conversion",()=>{it("should convert opening tags to parentheses",()=>{const result=sanitizeContent("Hello world
");expect(result).toBe("Hello (div)world(/div)")}),it("should convert tags with attributes to parentheses",()=>{const result=sanitizeContent('content
');expect(result).toBe('(div class="test")content(/div)')}),it("should preserve allowed safe tags",()=>{["b","blockquote","br","code","details","em","h1","h2","h3","h4","h5","h6","hr","i","li","ol","p","pre","strong","sub","summary","sup","table","tbody","td","th","thead","tr","ul"].forEach(tag=>{const result=sanitizeContent(`<${tag}>content${tag}>`);expect(result).toBe(`<${tag}>content${tag}>`)})}),it("should preserve self-closing br tags",()=>{const result=sanitizeContent("Hello
world");expect(result).toBe("Hello
world")}),it("should preserve br tags without slash",()=>{const result=sanitizeContent("Hello
world");expect(result).toBe("Hello
world")}),it("should convert self-closing tags that are not allowed",()=>{const result=sanitizeContent("Hello
world");expect(result).toBe("Hello (img/) world")}),it("should handle CDATA sections",()=>{const result=sanitizeContent("alert('xss')<\/script>]]>");expect(result).toBe("(![CDATA[(script)alert('xss')(/script)]])")}),it("should preserve inline formatting tags",()=>{const input="This is bold, italic, and bold too text.",result=sanitizeContent(input);expect(result).toBe(input)}),it("should preserve list structure tags",()=>{const input="",result=sanitizeContent(input);expect(result).toBe(input)}),it("should preserve ordered list tags",()=>{const input="- First
- Second
",result=sanitizeContent(input);expect(result).toBe(input)}),it("should preserve blockquote tags",()=>{const input="This is a quote
",result=sanitizeContent(input);expect(result).toBe(input)}),it("should handle mixed allowed tags with formatting",()=>{const input="This is bold and italic text.
New line here.
",result=sanitizeContent(input);expect(result).toBe(input)}),it("should handle nested list structure",()=>{const input="",result=sanitizeContent(input);expect(result).toBe(input)}),it("should preserve details and summary tags",()=>{const result1=sanitizeContent("content ");expect(result1).toBe("content ");const result2=sanitizeContent("content");expect(result2).toBe("content")}),it("should convert removed tags that are no longer allowed",()=>{const result3=sanitizeContent("content");expect(result3).toBe("(u)content(/u)")}),it("should preserve heading tags h1-h6",()=>{["h1","h2","h3","h4","h5","h6"].forEach(tag=>{const input=`<${tag}>Heading${tag}>`,result=sanitizeContent(input);expect(result).toBe(input)})}),it("should preserve hr tag",()=>{const result=sanitizeContent("Content before
Content after");expect(result).toBe("Content before
Content after")}),it("should preserve pre tag",()=>{const input="Code block content
",result=sanitizeContent(input);expect(result).toBe(input)}),it("should preserve sub and sup tags",()=>{const result1=sanitizeContent("H2O");expect(result1).toBe("H2O");const result2=sanitizeContent("E=mc2");expect(result2).toBe("E=mc2")}),it("should preserve table structure tags",()=>{const input="",result=sanitizeContent(input);expect(result).toBe(input)})}),describe("ANSI escape sequence removal",()=>{it("should remove ANSI color codes",()=>{const result=sanitizeContent("[31mred text[0m");expect(result).toBe("red text")}),it("should remove various ANSI codes",()=>{const result=sanitizeContent("[1;32mBold Green[0m");expect(result).toBe("Bold Green")})}),describe("control character removal",()=>{it("should remove control characters",()=>{const result=sanitizeContent("test\0content");expect(result).toBe("testcontent")}),it("should preserve newlines and tabs",()=>{const result=sanitizeContent("test\ncontent\twith\ttabs");expect(result).toBe("test\ncontent\twith\ttabs")}),it("should remove DEL character",()=>{const result=sanitizeContent("testcontent");expect(result).toBe("testcontent")})}),describe("URL protocol sanitization",()=>{it("should allow HTTPS URLs",()=>{const result=sanitizeContent("Visit https://github.com");expect(result).toBe("Visit https://github.com")}),it("should redact HTTP URLs",()=>{const result=sanitizeContent("Visit http://example.com");expect(result).toContain("(redacted)"),expect(mockCore.info).toHaveBeenCalled()}),it("should redact javascript: URLs",()=>{const result=sanitizeContent("Click javascript:alert('xss')");expect(result).toContain("(redacted)")}),it("should redact data: URLs",()=>{const result=sanitizeContent("Image data:image/png;base64,abc123");expect(result).toContain("(redacted)")}),it("should preserve file paths with colons",()=>{const result=sanitizeContent("C:\\path\\to\\file");expect(result).toBe("C:\\path\\to\\file")}),it("should preserve namespace patterns",()=>{const result=sanitizeContent("std::vector::push_back");expect(result).toBe("std::vector::push_back")})}),describe("URL domain filtering",()=>{it("should allow default GitHub domains",()=>{["https://github.com/repo","https://api.github.com/endpoint","https://raw.githubusercontent.com/file","https://example.github.io/page"].forEach(url=>{const result=sanitizeContent(`Visit ${url}`);expect(result).toBe(`Visit ${url}`)})}),it("should redact disallowed domains",()=>{const result=sanitizeContent("Visit https://evil.com/malicious");expect(result).toContain("(redacted)"),expect(mockCore.info).toHaveBeenCalled()}),it("should use custom allowed domains from environment",()=>{process.env.GH_AW_ALLOWED_DOMAINS="example.com,trusted.net";const result=sanitizeContent("Visit https://example.com/page");expect(result).toBe("Visit https://example.com/page")}),it("should extract and allow GitHub Enterprise domains",()=>{process.env.GITHUB_SERVER_URL="https://github.company.com";const result=sanitizeContent("Visit https://github.company.com/repo");expect(result).toBe("Visit https://github.company.com/repo")}),it("should allow subdomains of allowed domains",()=>{const result=sanitizeContent("Visit https://subdomain.github.com/page");expect(result).toBe("Visit https://subdomain.github.com/page")}),it("should log redacted domains",()=>{sanitizeContent("Visit https://verylongdomainnamefortest.com/page"),expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Redacted URL:")),expect(mockCore.debug).toHaveBeenCalledWith(expect.stringContaining("Redacted URL (full):"))})}),describe("bot trigger neutralization",()=>{it("should neutralize 'fixes #123' patterns",()=>{const result=sanitizeContent("This fixes #123");expect(result).toBe("This `fixes #123`")}),it("should neutralize 'closes #456' patterns",()=>{const result=sanitizeContent("PR closes #456");expect(result).toBe("PR `closes #456`")}),it("should neutralize 'resolves #789' patterns",()=>{const result=sanitizeContent("This resolves #789");expect(result).toBe("This `resolves #789`")}),it("should handle various bot trigger verbs",()=>{["fix","fixes","close","closes","resolve","resolves"].forEach(verb=>{const result=sanitizeContent(`This ${verb} #123`);expect(result).toBe(`This \`${verb} #123\``)})}),it("should neutralize alphanumeric issue references",()=>{const result=sanitizeContent("fixes #abc123def");expect(result).toBe("`fixes #abc123def`")})}),describe("content truncation",()=>{it("should truncate content exceeding max length",()=>{const longContent="x".repeat(6e5),result=sanitizeContent(longContent);expect(result.length).toBeLessThan(longContent.length),expect(result).toContain("[Content truncated due to length]")}),it("should truncate content exceeding max lines",()=>{const manyLines=Array(7e4).fill("line").join("\n"),result=sanitizeContent(manyLines);expect(result.split("\n").length).toBeLessThan(7e4),expect(result).toContain("[Content truncated due to line count]")}),it("should respect custom max length parameter",()=>{const content="x".repeat(200),result=sanitizeContent(content,100);expect(result.length).toBeLessThanOrEqual(150),expect(result).toContain("[Content truncated")}),it("should not truncate short content",()=>{const result=sanitizeContent("This is a short message");expect(result).toBe("This is a short message"),expect(result).not.toContain("[Content truncated")})}),describe("combined sanitization",()=>{it("should apply all sanitizations correctly",()=>{const result=sanitizeContent(" \n \x3c!-- comment --\x3e\n Hello @user, visit https://github.com\n & 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);
- });
- });
-});
+import{describe,it,expect,beforeEach,afterEach,vi}from"vitest";import fs from"fs";import path from"path";const mockCore={debug:vi.fn(),info:vi.fn(),notice:vi.fn(),warning:vi.fn(),error:vi.fn(),setFailed:vi.fn(),setOutput:vi.fn(),exportVariable:vi.fn(),setSecret:vi.fn(),getInput:vi.fn(),getBooleanInput:vi.fn(),getMultilineInput:vi.fn(),getState:vi.fn(),saveState:vi.fn(),startGroup:vi.fn(),endGroup:vi.fn(),group:vi.fn(),addPath:vi.fn(),setCommandEcho:vi.fn(),isDebug:vi.fn().mockReturnValue(!1),getIDToken:vi.fn(),toPlatformPath:vi.fn(),toPosixPath:vi.fn(),toWin32Path:vi.fn(),summary:{addRaw:vi.fn().mockReturnThis(),write:vi.fn().mockResolvedValue()}};global.core=mockCore,describe("sanitize_output.cjs",()=>{let sanitizeScript,sanitizeContentFunction;beforeEach(()=>{vi.clearAllMocks(),delete process.env.GH_AW_SAFE_OUTPUTS,delete process.env.GH_AW_ALLOWED_DOMAINS;const scriptPath=path.join(process.cwd(),"sanitize_output.cjs");sanitizeScript=fs.readFileSync(scriptPath,"utf8");const scriptWithExport=sanitizeScript.replace("await main();","global.testSanitizeContent = sanitizeContent;");eval(scriptWithExport),sanitizeContentFunction=global.testSanitizeContent}),describe("sanitizeContent function",()=>{it("should handle null and undefined inputs",()=>{expect(sanitizeContentFunction(null)).toBe(""),expect(sanitizeContentFunction(void 0)).toBe(""),expect(sanitizeContentFunction("")).toBe("")}),it("should neutralize @mentions by wrapping in backticks",()=>{const result=sanitizeContentFunction("Hello @user and @org/team");expect(result).toContain("`@user`"),expect(result).toContain("`@org/team`")}),it("should not neutralize @mentions inside code blocks",()=>{const result=sanitizeContentFunction("Check `@user` in code and @realuser outside");expect(result).toContain("`@user`"),expect(result).toContain("`@realuser`")}),it("should neutralize bot trigger phrases",()=>{const result=sanitizeContentFunction("This fixes #123 and closes #456. Also resolves #789");expect(result).toContain("`fixes #123`"),expect(result).toContain("`closes #456`"),expect(result).toContain("`resolves #789`")}),it("should remove control characters except newlines and tabs",()=>{const result=sanitizeContentFunction("Hello\0world\f\nNext line\tbad");expect(result).not.toContain("\0"),expect(result).not.toContain("\f"),expect(result).not.toContain(""),expect(result).toContain("\n"),expect(result).toContain("\t")}),it("should convert XML tags to parentheses format",()=>{const result=sanitizeContentFunction(']]>");
+ expect(result).toBe("(![CDATA[(script)alert('xss')(/script)]])");
+ });
+
+ it("should preserve inline formatting tags", () => {
+ const input = "This is bold, italic, and bold too text.";
+ const result = sanitizeContent(input);
+ expect(result).toBe(input);
+ });
+
+ it("should preserve list structure tags", () => {
+ const input = "";
+ const result = sanitizeContent(input);
+ expect(result).toBe(input);
+ });
+
+ it("should preserve ordered list tags", () => {
+ const input = "- First
- Second
";
+ const result = sanitizeContent(input);
+ expect(result).toBe(input);
+ });
+
+ it("should preserve blockquote tags", () => {
+ const input = "This is a quote
";
+ const result = sanitizeContent(input);
+ expect(result).toBe(input);
+ });
+
+ it("should handle mixed allowed tags with formatting", () => {
+ const input = "This is bold and italic text.
New line here.
";
+ const result = sanitizeContent(input);
+ expect(result).toBe(input);
+ });
+
+ it("should handle nested list structure", () => {
+ const input = "";
+ const result = sanitizeContent(input);
+ expect(result).toBe(input);
+ });
+
+ it("should preserve details and summary tags", () => {
+ const result1 = sanitizeContent("content ");
+ expect(result1).toBe("content ");
+
+ const result2 = sanitizeContent("content");
+ expect(result2).toBe("content");
+ });
+
+ it("should convert removed tags that are no longer allowed", () => {
+ // Tag that was previously allowed but is now removed: u
+ const result3 = sanitizeContent("content");
+ expect(result3).toBe("(u)content(/u)");
+ });
+
+ it("should preserve heading tags h1-h6", () => {
+ const headings = ["h1", "h2", "h3", "h4", "h5", "h6"];
+ headings.forEach(tag => {
+ const input = `<${tag}>Heading${tag}>`;
+ const result = sanitizeContent(input);
+ expect(result).toBe(input);
+ });
+ });
+
+ it("should preserve hr tag", () => {
+ const result = sanitizeContent("Content before
Content after");
+ expect(result).toBe("Content before
Content after");
+ });
+
+ it("should preserve pre tag", () => {
+ const input = "Code block content
";
+ const result = sanitizeContent(input);
+ expect(result).toBe(input);
+ });
+
+ it("should preserve sub and sup tags", () => {
+ const input1 = "H2O";
+ const result1 = sanitizeContent(input1);
+ expect(result1).toBe(input1);
+
+ const input2 = "E=mc2";
+ const result2 = sanitizeContent(input2);
+ expect(result2).toBe(input2);
+ });
+
+ it("should preserve table structure tags", () => {
+ const input = "";
+ const result = sanitizeContent(input);
+ expect(result).toBe(input);
+ });
+ });
+
+ describe("ANSI escape sequence removal", () => {
+ it("should remove ANSI color codes", () => {
+ const result = sanitizeContent("\x1b[31mred text\x1b[0m");
+ expect(result).toBe("red text");
+ });
+
+ it("should remove various ANSI codes", () => {
+ const result = sanitizeContent("\x1b[1;32mBold Green\x1b[0m");
+ expect(result).toBe("Bold Green");
+ });
+ });
+
+ describe("control character removal", () => {
+ it("should remove control characters", () => {
+ const result = sanitizeContent("test\x00\x01\x02\x03content");
+ expect(result).toBe("testcontent");
+ });
+
+ it("should preserve newlines and tabs", () => {
+ const result = sanitizeContent("test\ncontent\twith\ttabs");
+ expect(result).toBe("test\ncontent\twith\ttabs");
+ });
+
+ it("should remove DEL character", () => {
+ const result = sanitizeContent("test\x7Fcontent");
+ expect(result).toBe("testcontent");
+ });
+ });
+
+ describe("URL protocol sanitization", () => {
+ it("should allow HTTPS URLs", () => {
+ const result = sanitizeContent("Visit https://github.com");
+ expect(result).toBe("Visit https://github.com");
+ });
+
+ it("should redact HTTP URLs", () => {
+ const result = sanitizeContent("Visit http://example.com");
+ expect(result).toContain("(redacted)");
+ expect(mockCore.info).toHaveBeenCalled();
+ });
+
+ it("should redact javascript: URLs", () => {
+ const result = sanitizeContent("Click javascript:alert('xss')");
+ expect(result).toContain("(redacted)");
+ });
+
+ it("should redact data: URLs", () => {
+ const result = sanitizeContent("Image data:image/png;base64,abc123");
+ expect(result).toContain("(redacted)");
+ });
+
+ it("should preserve file paths with colons", () => {
+ const result = sanitizeContent("C:\\path\\to\\file");
+ expect(result).toBe("C:\\path\\to\\file");
+ });
+
+ it("should preserve namespace patterns", () => {
+ const result = sanitizeContent("std::vector::push_back");
+ expect(result).toBe("std::vector::push_back");
+ });
+ });
+
+ describe("URL domain filtering", () => {
+ it("should allow default GitHub domains", () => {
+ const urls = ["https://github.com/repo", "https://api.github.com/endpoint", "https://raw.githubusercontent.com/file", "https://example.github.io/page"];
+
+ urls.forEach(url => {
+ const result = sanitizeContent(`Visit ${url}`);
+ expect(result).toBe(`Visit ${url}`);
+ });
+ });
+
+ it("should redact disallowed domains", () => {
+ const result = sanitizeContent("Visit https://evil.com/malicious");
+ expect(result).toContain("(redacted)");
+ expect(mockCore.info).toHaveBeenCalled();
+ });
+
+ it("should use custom allowed domains from environment", () => {
+ process.env.GH_AW_ALLOWED_DOMAINS = "example.com,trusted.net";
+ const result = sanitizeContent("Visit https://example.com/page");
+ expect(result).toBe("Visit https://example.com/page");
+ });
+
+ it("should extract and allow GitHub Enterprise domains", () => {
+ process.env.GITHUB_SERVER_URL = "https://github.company.com";
+ const result = sanitizeContent("Visit https://github.company.com/repo");
+ expect(result).toBe("Visit https://github.company.com/repo");
+ });
+
+ it("should allow subdomains of allowed domains", () => {
+ const result = sanitizeContent("Visit https://subdomain.github.com/page");
+ expect(result).toBe("Visit https://subdomain.github.com/page");
+ });
+
+ it("should log redacted domains", () => {
+ sanitizeContent("Visit https://verylongdomainnamefortest.com/page");
+ expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Redacted URL:"));
+ expect(mockCore.debug).toHaveBeenCalledWith(expect.stringContaining("Redacted URL (full):"));
+ });
+ });
+
+ describe("bot trigger neutralization", () => {
+ it("should neutralize 'fixes #123' patterns", () => {
+ const result = sanitizeContent("This fixes #123");
+ expect(result).toBe("This `fixes #123`");
+ });
+
+ it("should neutralize 'closes #456' patterns", () => {
+ const result = sanitizeContent("PR closes #456");
+ expect(result).toBe("PR `closes #456`");
+ });
+
+ it("should neutralize 'resolves #789' patterns", () => {
+ const result = sanitizeContent("This resolves #789");
+ expect(result).toBe("This `resolves #789`");
+ });
+
+ it("should handle various bot trigger verbs", () => {
+ const triggers = ["fix", "fixes", "close", "closes", "resolve", "resolves"];
+ triggers.forEach(verb => {
+ const result = sanitizeContent(`This ${verb} #123`);
+ expect(result).toBe(`This \`${verb} #123\``);
+ });
+ });
+
+ it("should neutralize alphanumeric issue references", () => {
+ const result = sanitizeContent("fixes #abc123def");
+ expect(result).toBe("`fixes #abc123def`");
+ });
+ });
+
+ describe("content truncation", () => {
+ it("should truncate content exceeding max length", () => {
+ const longContent = "x".repeat(600000);
+ const result = sanitizeContent(longContent);
+
+ expect(result.length).toBeLessThan(longContent.length);
+ expect(result).toContain("[Content truncated due to length]");
+ });
+
+ it("should truncate content exceeding max lines", () => {
+ const manyLines = Array(70000).fill("line").join("\n");
+ const result = sanitizeContent(manyLines);
+
+ expect(result.split("\n").length).toBeLessThan(70000);
+ expect(result).toContain("[Content truncated due to line count]");
+ });
+
+ it("should respect custom max length parameter", () => {
+ const content = "x".repeat(200);
+ const result = sanitizeContent(content, 100);
+
+ expect(result.length).toBeLessThanOrEqual(100 + 50); // +50 for truncation message
+ expect(result).toContain("[Content truncated");
+ });
+
+ it("should not truncate short content", () => {
+ const shortContent = "This is a short message";
+ const result = sanitizeContent(shortContent);
+
+ expect(result).toBe(shortContent);
+ expect(result).not.toContain("[Content truncated");
+ });
+ });
+
+ describe("combined sanitization", () => {
+ it("should apply all sanitizations correctly", () => {
+ const input = `
+
+ Hello @user, visit https://github.com
+
+ This fixes #123
+ \x1b[31mRed text\x1b[0m
+ `;
+
+ const result = sanitizeContent(input);
+
+ expect(result).not.toContain("");
+ expect(result).toContain("`@user`");
+ expect(result).toContain("https://github.com");
+ expect(result).not.toContain(""];
+
+ maliciousInputs.forEach(input => {
+ const result = sanitizeContent(input);
+ expect(result).not.toContain("
{
+ const input = "";
+ const result = sanitizeContent(input);
+
+ expect(result).toBe(input);
+ });
+ });
+
+ describe("edge cases", () => {
+ it("should handle empty string", () => {
+ expect(sanitizeContent("")).toBe("");
+ });
+
+ it("should handle whitespace-only input", () => {
+ expect(sanitizeContent(" \n\t ")).toBe("");
+ });
+
+ it("should handle content with only control characters", () => {
+ const result = sanitizeContent("\x00\x01\x02\x03");
+ expect(result).toBe("");
+ });
+
+ it("should handle content with multiple consecutive spaces", () => {
+ const result = sanitizeContent("hello world");
+ expect(result).toBe("hello world");
+ });
+
+ it("should handle Unicode characters", () => {
+ const result = sanitizeContent("Hello 世界 🌍");
+ expect(result).toBe("Hello 世界 🌍");
+ });
+
+ it("should handle URLs in query parameters", () => {
+ const input = "https://github.com/redirect?url=https://github.com/target";
+ const result = sanitizeContent(input);
+
+ expect(result).toContain("github.com");
+ expect(result).not.toContain("(redacted)");
+ });
+
+ it("should handle nested backticks", () => {
+ const result = sanitizeContent("Already `@user` and @other");
+ expect(result).toBe("Already `@user` and `@other`");
+ });
+ });
+
+ describe("redacted domains collection", () => {
+ let getRedactedDomains;
+ let clearRedactedDomains;
+ let writeRedactedDomainsLog;
+ const fs = require("fs");
+ const path = require("path");
+
+ beforeEach(async () => {
+ const module = await import("./sanitize_content.cjs");
+ getRedactedDomains = module.getRedactedDomains;
+ clearRedactedDomains = module.clearRedactedDomains;
+ writeRedactedDomainsLog = module.writeRedactedDomainsLog;
+ // Clear collected domains before each test
+ clearRedactedDomains();
+ });
+
+ it("should collect redacted HTTPS domains", () => {
+ sanitizeContent("Visit https://evil.com/malware");
+ const domains = getRedactedDomains();
+ expect(domains.length).toBe(1);
+ expect(domains[0]).toBe("evil.com");
+ });
+
+ it("should collect redacted HTTP domains", () => {
+ sanitizeContent("Visit http://example.com");
+ const domains = getRedactedDomains();
+ expect(domains.length).toBe(1);
+ expect(domains[0]).toBe("example.com");
+ });
+
+ it("should collect redacted dangerous protocols", () => {
+ sanitizeContent("Click javascript:alert(1)");
+ const domains = getRedactedDomains();
+ expect(domains.length).toBe(1);
+ expect(domains[0]).toBe("javascript:");
+ });
+
+ it("should collect multiple redacted domains", () => {
+ sanitizeContent("Visit https://bad1.com and http://bad2.com");
+ const domains = getRedactedDomains();
+ expect(domains.length).toBe(2);
+ expect(domains).toContain("bad1.com");
+ expect(domains).toContain("bad2.com");
+ });
+
+ it("should not collect allowed domains", () => {
+ sanitizeContent("Visit https://github.com/repo");
+ const domains = getRedactedDomains();
+ expect(domains.length).toBe(0);
+ });
+
+ it("should clear collected domains", () => {
+ sanitizeContent("Visit https://evil.com");
+ expect(getRedactedDomains().length).toBe(1);
+ clearRedactedDomains();
+ expect(getRedactedDomains().length).toBe(0);
+ });
+
+ it("should return a copy of domains array", () => {
+ sanitizeContent("Visit https://evil.com");
+ const domains1 = getRedactedDomains();
+ const domains2 = getRedactedDomains();
+ expect(domains1).not.toBe(domains2);
+ expect(domains1).toEqual(domains2);
+ });
+
+ describe("writeRedactedDomainsLog", () => {
+ const testDir = "/tmp/gh-aw-test-redacted";
+ const testFile = `${testDir}/redacted-urls.log`;
+
+ afterEach(() => {
+ // Clean up test files
+ if (fs.existsSync(testFile)) {
+ fs.unlinkSync(testFile);
+ }
+ if (fs.existsSync(testDir)) {
+ fs.rmdirSync(testDir, { recursive: true });
+ }
+ });
+
+ it("should return null when no domains collected", () => {
+ const result = writeRedactedDomainsLog(testFile);
+ expect(result).toBeNull();
+ expect(fs.existsSync(testFile)).toBe(false);
+ });
+
+ it("should write domains to log file", () => {
+ sanitizeContent("Visit https://evil.com/malware");
+ const result = writeRedactedDomainsLog(testFile);
+ expect(result).toBe(testFile);
+ expect(fs.existsSync(testFile)).toBe(true);
+
+ const content = fs.readFileSync(testFile, "utf8");
+ expect(content).toContain("evil.com");
+ // Should NOT contain the full URL, only the domain
+ expect(content).not.toContain("https://evil.com/malware");
+ });
+
+ it("should write multiple domains to log file", () => {
+ sanitizeContent("Visit https://bad1.com and http://bad2.com");
+ writeRedactedDomainsLog(testFile);
+
+ const content = fs.readFileSync(testFile, "utf8");
+ const lines = content.trim().split("\n");
+ expect(lines.length).toBe(2);
+ expect(content).toContain("bad1.com");
+ expect(content).toContain("bad2.com");
+ });
+
+ it("should create directory if it does not exist", () => {
+ const nestedFile = `${testDir}/nested/redacted-urls.log`;
+ sanitizeContent("Visit https://evil.com");
+ writeRedactedDomainsLog(nestedFile);
+ expect(fs.existsSync(nestedFile)).toBe(true);
+
+ // Clean up nested directory
+ fs.unlinkSync(nestedFile);
+ fs.rmdirSync(path.dirname(nestedFile));
+ });
+
+ it("should use default path when not specified", () => {
+ const defaultPath = "/tmp/gh-aw/redacted-urls.log";
+ sanitizeContent("Visit https://evil.com");
+ const result = writeRedactedDomainsLog();
+ expect(result).toBe(defaultPath);
+ expect(fs.existsSync(defaultPath)).toBe(true);
+
+ // Clean up
+ fs.unlinkSync(defaultPath);
+ });
+ });
+ });
+});
diff --git a/pkg/workflow/js/sanitize_content_core.cjs b/pkg/workflow/js/sanitize_content_core.cjs
index 8ebbbbd4b2b..ddf4c364705 100644
--- a/pkg/workflow/js/sanitize_content_core.cjs
+++ b/pkg/workflow/js/sanitize_content_core.cjs
@@ -1 +1,396 @@
-const redactedDomains=[];function getRedactedDomains(){return[...redactedDomains]}function addRedactedDomain(domain){redactedDomains.push(domain)}function clearRedactedDomains(){redactedDomains.length=0}function writeRedactedDomainsLog(filePath){if(0===redactedDomains.length)return null;const fs=require("fs"),targetPath=filePath||"/tmp/gh-aw/redacted-urls.log",dir=require("path").dirname(targetPath);return fs.existsSync(dir)||fs.mkdirSync(dir,{recursive:!0}),fs.writeFileSync(targetPath,redactedDomains.join("\n")+"\n"),targetPath}function extractDomainsFromUrl(url){if(!url||"string"!=typeof url)return[];try{const hostname=new URL(url).hostname.toLowerCase(),domains=[hostname];return"github.com"===hostname?(domains.push("api.github.com"),domains.push("raw.githubusercontent.com"),domains.push("*.githubusercontent.com")):hostname.startsWith("api.")||(domains.push("api."+hostname),domains.push("raw."+hostname)),domains}catch(e){return[]}}function sanitizeContentCore(content,maxLength){if(!content||"string"!=typeof content)return"";const allowedDomainsEnv=process.env.GH_AW_ALLOWED_DOMAINS;let allowedDomains=allowedDomainsEnv?allowedDomainsEnv.split(",").map(d=>d.trim()).filter(d=>d):["github.com","github.io","githubusercontent.com","githubassets.com","github.dev","codespaces.new"];const githubServerUrl=process.env.GITHUB_SERVER_URL,githubApiUrl=process.env.GITHUB_API_URL;if(githubServerUrl){const serverDomains=extractDomainsFromUrl(githubServerUrl);allowedDomains=allowedDomains.concat(serverDomains)}if(githubApiUrl){const apiDomains=extractDomainsFromUrl(githubApiUrl);allowedDomains=allowedDomains.concat(apiDomains)}allowedDomains=[...new Set(allowedDomains)];let sanitized=content;sanitized=sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g,""),sanitized=sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g,""),sanitized=function(s){const commandName=process.env.GH_AW_COMMAND;if(!commandName)return s;const escapedCommand=commandName.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");return s.replace(new RegExp(`^(\\s*)/(${escapedCommand})\\b`,"i"),"$1`/$2`")}(sanitized),sanitized=sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g,(m,p1,p2)=>("undefined"!=typeof core&&core.info&&core.info(`Escaped mention: @${p2} (not in allowed list)`),`${p1}\`@${p2}\``)),sanitized=sanitized.replace(//g,"").replace(//g,""),sanitized=function(s){const allowedTags=["b","blockquote","br","code","details","em","h1","h2","h3","h4","h5","h6","hr","i","li","ol","p","pre","strong","sub","summary","sup","table","tbody","td","th","thead","tr","ul"];return s=s.replace(//g,(match,content)=>`(![CDATA[${content.replace(/<(\/?[A-Za-z][A-Za-z0-9]*(?:[^>]*?))>/g,"($1)")}]])`),s.replace(/<(\/?[A-Za-z!][^>]*?)>/g,(match,tagContent)=>{const tagNameMatch=tagContent.match(/^\/?\s*([A-Za-z][A-Za-z0-9]*)/);if(tagNameMatch){const tagName=tagNameMatch[1].toLowerCase();if(allowedTags.includes(tagName))return match}return`(${tagContent})`})}(sanitized),sanitized=function(s){return s.replace(/((?:http|ftp|file|ssh|git):\/\/([\w.-]*)(?:[^\s]*)|(?:data|javascript|vbscript|about|mailto|tel):[^\s]+)/gi,(match,_fullMatch,domain)=>{if(domain){const domainLower=domain.toLowerCase(),truncated=domainLower.length>12?domainLower.substring(0,12)+"...":domainLower;"undefined"!=typeof core&&core.info&&core.info(`Redacted URL: ${truncated}`),"undefined"!=typeof core&&core.debug&&core.debug(`Redacted URL (full): ${match}`),addRedactedDomain(domainLower)}else{const protocolMatch=match.match(/^([^:]+):/);if(protocolMatch){const protocol=protocolMatch[1]+":",truncated=match.length>12?match.substring(0,12)+"...":match;"undefined"!=typeof core&&core.info&&core.info(`Redacted URL: ${truncated}`),"undefined"!=typeof core&&core.debug&&core.debug(`Redacted URL (full): ${match}`),addRedactedDomain(protocol)}}return"(redacted)"})}(sanitized),sanitized=function(s,allowed){return s.replace(/https:\/\/([\w.-]+(?::\d+)?)(\/(?:(?!https:\/\/)[^\s,])*)?/gi,(match,hostnameWithPort,pathPart)=>{const hostname=hostnameWithPort.split(":")[0].toLowerCase();if(pathPart=pathPart||"",allowed.some(allowedDomain=>{const normalizedAllowed=allowedDomain.toLowerCase();if(hostname===normalizedAllowed)return!0;if(normalizedAllowed.startsWith("*.")){const baseDomain=normalizedAllowed.substring(2);return hostname.endsWith("."+baseDomain)||hostname===baseDomain}return hostname.endsWith("."+normalizedAllowed)}))return match;{const truncated=hostname.length>12?hostname.substring(0,12)+"...":hostname;return"undefined"!=typeof core&&core.info&&core.info(`Redacted URL: ${truncated}`),"undefined"!=typeof core&&core.debug&&core.debug(`Redacted URL (full): ${match}`),addRedactedDomain(hostname),"(redacted)"}})}(sanitized,allowedDomains);const lines=sanitized.split("\n");if(maxLength=maxLength||524288,lines.length>65e3){const truncationMsg="\n[Content truncated due to line count]",truncatedLines=lines.slice(0,65e3).join("\n")+truncationMsg;sanitized=truncatedLines.length>maxLength?truncatedLines.substring(0,maxLength-truncationMsg.length)+truncationMsg:truncatedLines}else sanitized.length>maxLength&&(sanitized=sanitized.substring(0,maxLength)+"\n[Content truncated due to length]");return sanitized=function(s){return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi,(match,action,ref)=>`\`${action} #${ref}\``)}(sanitized),sanitized.trim()}module.exports={sanitizeContentCore,getRedactedDomains,addRedactedDomain,clearRedactedDomains,writeRedactedDomainsLog,extractDomainsFromUrl};
\ No newline at end of file
+// @ts-check
+/**
+ * Core sanitization utilities without mention filtering
+ * This module provides the base sanitization functions that don't require
+ * mention resolution or filtering. It's designed to be imported by both
+ * sanitize_content.cjs (full version) and sanitize_incoming_text.cjs (minimal version).
+ */
+
+/**
+ * Module-level set to collect redacted URL domains across sanitization calls.
+ * @type {string[]}
+ */
+const redactedDomains = [];
+
+/**
+ * Gets the list of redacted URL domains collected during sanitization.
+ * @returns {string[]} Array of redacted domain strings
+ */
+function getRedactedDomains() {
+ return [...redactedDomains];
+}
+
+/**
+ * Adds a domain to the redacted domains list
+ * @param {string} domain - Domain to add
+ */
+function addRedactedDomain(domain) {
+ redactedDomains.push(domain);
+}
+
+/**
+ * Clears the list of redacted URL domains.
+ * Useful for testing or resetting state between operations.
+ */
+function clearRedactedDomains() {
+ redactedDomains.length = 0;
+}
+
+/**
+ * Writes the collected redacted URL domains to a log file.
+ * Only creates the file if there are redacted domains.
+ * @param {string} [filePath] - Path to write the log file. Defaults to /tmp/gh-aw/redacted-urls.log
+ * @returns {string|null} The file path if written, null if no domains to write
+ */
+function writeRedactedDomainsLog(filePath) {
+ if (redactedDomains.length === 0) {
+ return null;
+ }
+
+ const fs = require("fs");
+ const path = require("path");
+ const targetPath = filePath || "/tmp/gh-aw/redacted-urls.log";
+
+ // Ensure directory exists
+ const dir = path.dirname(targetPath);
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+
+ // Write domains to file, one per line
+ fs.writeFileSync(targetPath, redactedDomains.join("\n") + "\n");
+
+ return targetPath;
+}
+
+/**
+ * Extract domains from a URL and return an array of domain variations
+ * @param {string} url - The URL to extract domains from
+ * @returns {string[]} Array of domain variations
+ */
+function extractDomainsFromUrl(url) {
+ if (!url || typeof url !== "string") {
+ return [];
+ }
+
+ try {
+ // Parse the URL
+ const urlObj = new URL(url);
+ const hostname = urlObj.hostname.toLowerCase();
+
+ // Return both the exact hostname and common variations
+ const domains = [hostname];
+
+ // For github.com, add api and raw content domain variations
+ if (hostname === "github.com") {
+ domains.push("api.github.com");
+ domains.push("raw.githubusercontent.com");
+ domains.push("*.githubusercontent.com");
+ }
+ // For custom GitHub Enterprise domains, add api. prefix and raw content variations
+ else if (!hostname.startsWith("api.")) {
+ domains.push("api." + hostname);
+ // For GitHub Enterprise, raw content is typically served from raw.hostname
+ domains.push("raw." + hostname);
+ }
+
+ return domains;
+ } catch (e) {
+ // Invalid URL, return empty array
+ return [];
+ }
+}
+
+/**
+ * Core sanitization function without mention filtering
+ * @param {string} content - The content to sanitize
+ * @param {number} [maxLength] - Maximum length of content (default: 524288)
+ * @returns {string} The sanitized content
+ */
+function sanitizeContentCore(content, maxLength) {
+ if (!content || typeof content !== "string") {
+ return "";
+ }
+
+ // Read allowed domains from environment variable
+ const allowedDomainsEnv = process.env.GH_AW_ALLOWED_DOMAINS;
+ const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"];
+
+ let allowedDomains = allowedDomainsEnv
+ ? allowedDomainsEnv
+ .split(",")
+ .map(d => d.trim())
+ .filter(d => d)
+ : defaultAllowedDomains;
+
+ // Extract and add GitHub domains from GitHub context URLs
+ // This handles GitHub Enterprise deployments with custom domains
+ const githubServerUrl = process.env.GITHUB_SERVER_URL;
+ const githubApiUrl = process.env.GITHUB_API_URL;
+
+ if (githubServerUrl) {
+ const serverDomains = extractDomainsFromUrl(githubServerUrl);
+ allowedDomains = allowedDomains.concat(serverDomains);
+ }
+
+ if (githubApiUrl) {
+ const apiDomains = extractDomainsFromUrl(githubApiUrl);
+ allowedDomains = allowedDomains.concat(apiDomains);
+ }
+
+ // Remove duplicates
+ allowedDomains = [...new Set(allowedDomains)];
+
+ let sanitized = content;
+
+ // Remove ANSI escape sequences and control characters early
+ // This must happen before mention neutralization to avoid creating bare mentions
+ // when control characters are removed between @ and username
+ sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, "");
+ // Remove control characters except newlines (\n), tabs (\t), and carriage returns (\r)
+ sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
+
+ // Neutralize commands at the start of text (e.g., /bot-name)
+ sanitized = neutralizeCommands(sanitized);
+
+ // Neutralize ALL @mentions (no filtering in core version)
+ sanitized = neutralizeAllMentions(sanitized);
+
+ // Remove XML comments first
+ sanitized = removeXmlComments(sanitized);
+
+ // Convert XML tags to parentheses format to prevent injection
+ sanitized = convertXmlTags(sanitized);
+
+ // URI filtering - replace non-https protocols with "(redacted)"
+ sanitized = sanitizeUrlProtocols(sanitized);
+
+ // Domain filtering for HTTPS URIs
+ sanitized = sanitizeUrlDomains(sanitized, allowedDomains);
+
+ // Check line count before length to provide more specific truncation message
+ const lines = sanitized.split("\n");
+ const maxLines = 65000;
+ maxLength = maxLength || 524288;
+
+ // If content has too many lines, truncate by lines (primary limit)
+ if (lines.length > maxLines) {
+ const truncationMsg = "\n[Content truncated due to line count]";
+ const truncatedLines = lines.slice(0, maxLines).join("\n") + truncationMsg;
+
+ // If still too long after line truncation, shorten but keep the line count message
+ if (truncatedLines.length > maxLength) {
+ sanitized = truncatedLines.substring(0, maxLength - truncationMsg.length) + truncationMsg;
+ } else {
+ sanitized = truncatedLines;
+ }
+ } else if (sanitized.length > maxLength) {
+ sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]";
+ }
+
+ // Neutralize common bot trigger phrases
+ sanitized = neutralizeBotTriggers(sanitized);
+
+ // Trim excessive whitespace
+ return sanitized.trim();
+
+ /**
+ * Remove unknown domains
+ * @param {string} s - The string to process
+ * @param {string[]} allowed - List of allowed domains
+ * @returns {string} The string with unknown domains redacted
+ */
+ function sanitizeUrlDomains(s, allowed) {
+ // Match HTTPS URLs with optional port and path
+ // This regex is designed to:
+ // 1. Match https:// URIs with explicit protocol
+ // 2. Capture the hostname/domain
+ // 3. Allow optional port (:8080)
+ // 4. Allow optional path and query string (but not trailing commas/periods)
+ // 5. Stop before another https:// URL in query params (using negative lookahead)
+ const httpsUrlRegex = /https:\/\/([\w.-]+(?::\d+)?)(\/(?:(?!https:\/\/)[^\s,])*)?/gi;
+
+ return s.replace(httpsUrlRegex, (match, hostnameWithPort, pathPart) => {
+ // Extract just the hostname (remove port if present)
+ const hostname = hostnameWithPort.split(":")[0].toLowerCase();
+ pathPart = pathPart || "";
+
+ // Check if domain is in the allowed list or is a subdomain of an allowed domain
+ const isAllowed = allowed.some(allowedDomain => {
+ const normalizedAllowed = allowedDomain.toLowerCase();
+
+ // Exact match
+ if (hostname === normalizedAllowed) {
+ return true;
+ }
+
+ // Wildcard match (*.example.com matches subdomain.example.com)
+ if (normalizedAllowed.startsWith("*.")) {
+ const baseDomain = normalizedAllowed.substring(2); // Remove *.
+ return hostname.endsWith("." + baseDomain) || hostname === baseDomain;
+ }
+
+ // Subdomain match (example.com matches subdomain.example.com)
+ return hostname.endsWith("." + normalizedAllowed);
+ });
+
+ if (isAllowed) {
+ return match; // Keep the full URL as-is
+ } else {
+ // Redact the domain but preserve the protocol and structure for debugging
+ const truncated = hostname.length > 12 ? hostname.substring(0, 12) + "..." : hostname;
+ if (typeof core !== "undefined" && core.info) {
+ core.info(`Redacted URL: ${truncated}`);
+ }
+ if (typeof core !== "undefined" && core.debug) {
+ core.debug(`Redacted URL (full): ${match}`);
+ }
+ addRedactedDomain(hostname);
+ return "(redacted)";
+ }
+ });
+ }
+
+ /**
+ * Sanitize URL protocols - replace non-https with (redacted)
+ * @param {string} s - The string to process
+ * @returns {string} The string with non-https protocols redacted
+ */
+ function sanitizeUrlProtocols(s) {
+ // Match common non-https protocols
+ // This regex matches: protocol://domain or protocol:path or incomplete protocol://
+ // Examples: http://, ftp://, file://, data:, javascript:, mailto:, tel:, ssh://, git://
+ // The regex also matches incomplete protocols like "http://" or "ftp://" without a domain
+ // Note: No word boundary check to catch protocols even when preceded by word characters
+ return s.replace(/((?:http|ftp|file|ssh|git):\/\/([\w.-]*)(?:[^\s]*)|(?:data|javascript|vbscript|about|mailto|tel):[^\s]+)/gi, (match, _fullMatch, domain) => {
+ // Extract domain for http/ftp/file/ssh/git protocols
+ if (domain) {
+ const domainLower = domain.toLowerCase();
+ const truncated = domainLower.length > 12 ? domainLower.substring(0, 12) + "..." : domainLower;
+ if (typeof core !== "undefined" && core.info) {
+ core.info(`Redacted URL: ${truncated}`);
+ }
+ if (typeof core !== "undefined" && core.debug) {
+ core.debug(`Redacted URL (full): ${match}`);
+ }
+ addRedactedDomain(domainLower);
+ } else {
+ // For other protocols (data:, javascript:, etc.), track the protocol itself
+ const protocolMatch = match.match(/^([^:]+):/);
+ if (protocolMatch) {
+ const protocol = protocolMatch[1] + ":";
+ // Truncate the matched URL for logging (keep first 12 chars + "...")
+ const truncated = match.length > 12 ? match.substring(0, 12) + "..." : match;
+ if (typeof core !== "undefined" && core.info) {
+ core.info(`Redacted URL: ${truncated}`);
+ }
+ if (typeof core !== "undefined" && core.debug) {
+ core.debug(`Redacted URL (full): ${match}`);
+ }
+ addRedactedDomain(protocol);
+ }
+ }
+ return "(redacted)";
+ });
+ }
+
+ /**
+ * Neutralizes commands at the start of text by wrapping them in backticks
+ * @param {string} s - The string to process
+ * @returns {string} The string with neutralized commands
+ */
+ function neutralizeCommands(s) {
+ const commandName = process.env.GH_AW_COMMAND;
+ if (!commandName) {
+ return s;
+ }
+
+ // Escape special regex characters in command name
+ const escapedCommand = commandName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+
+ // Neutralize /command at the start of text (with optional leading whitespace)
+ // Only match at the start of the string or after leading whitespace
+ return s.replace(new RegExp(`^(\\s*)/(${escapedCommand})\\b`, "i"), "$1`/$2`");
+ }
+
+ /**
+ * Neutralizes ALL @mentions by wrapping them in backticks
+ * This is the core version without any filtering
+ * @param {string} s - The string to process
+ * @returns {string} The string with neutralized mentions
+ */
+ function neutralizeAllMentions(s) {
+ // Replace @name or @org/team outside code with `@name`
+ // No filtering - all mentions are neutralized
+ return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (m, p1, p2) => {
+ // Log when a mention is escaped to help debug issues
+ if (typeof core !== "undefined" && core.info) {
+ core.info(`Escaped mention: @${p2} (not in allowed list)`);
+ }
+ return `${p1}\`@${p2}\``;
+ });
+ }
+
+ /**
+ * Removes XML comments from content
+ * @param {string} s - The string to process
+ * @returns {string} The string with XML comments removed
+ */
+ function removeXmlComments(s) {
+ // Remove and malformed
+ return s.replace(//g, "").replace(//g, "");
+ }
+
+ /**
+ * Converts XML/HTML tags to parentheses format to prevent injection
+ * @param {string} s - The string to process
+ * @returns {string} The string with XML tags converted to parentheses
+ */
+ function convertXmlTags(s) {
+ // Allow safe HTML tags: b, blockquote, br, code, details, em, h1–h6, hr, i, li, ol, p, pre, strong, sub, summary, sup, table, tbody, td, th, thead, tr, ul
+ const allowedTags = ["b", "blockquote", "br", "code", "details", "em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "li", "ol", "p", "pre", "strong", "sub", "summary", "sup", "table", "tbody", "td", "th", "thead", "tr", "ul"];
+
+ // First, process CDATA sections specially - convert tags inside them and the CDATA markers
+ s = s.replace(//g, (match, content) => {
+ // Convert tags inside CDATA content
+ const convertedContent = content.replace(/<(\/?[A-Za-z][A-Za-z0-9]*(?:[^>]*?))>/g, "($1)");
+ // Return with CDATA markers also converted to parentheses
+ return `(![CDATA[${convertedContent}]])`;
+ });
+
+ // Convert opening tags: or to (tag) or (tag attr="value")
+ // Convert closing tags: to (/tag)
+ // Convert self-closing tags: or to (tag/) or (tag /)
+ // But preserve allowed safe tags
+ return s.replace(/<(\/?[A-Za-z!][^>]*?)>/g, (match, tagContent) => {
+ // Extract tag name from the content (handle closing tags and attributes)
+ const tagNameMatch = tagContent.match(/^\/?\s*([A-Za-z][A-Za-z0-9]*)/);
+ if (tagNameMatch) {
+ const tagName = tagNameMatch[1].toLowerCase();
+ if (allowedTags.includes(tagName)) {
+ return match; // Preserve allowed tags
+ }
+ }
+ return `(${tagContent})`; // Convert other tags to parentheses
+ });
+ }
+
+ /**
+ * Neutralizes bot trigger phrases by wrapping them in backticks
+ * @param {string} s - The string to process
+ * @returns {string} The string with neutralized bot triggers
+ */
+ function neutralizeBotTriggers(s) {
+ // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc.
+ return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``);
+ }
+}
+
+module.exports = {
+ sanitizeContentCore,
+ getRedactedDomains,
+ addRedactedDomain,
+ clearRedactedDomains,
+ writeRedactedDomainsLog,
+ extractDomainsFromUrl,
+};
diff --git a/pkg/workflow/js/sanitize_content_old.cjs b/pkg/workflow/js/sanitize_content_old.cjs
index 133e3bcb381..e75392206d8 100644
--- a/pkg/workflow/js/sanitize_content_old.cjs
+++ b/pkg/workflow/js/sanitize_content_old.cjs
@@ -1 +1,404 @@
-const redactedDomains=[];function getRedactedDomains(){return[...redactedDomains]}function clearRedactedDomains(){redactedDomains.length=0}function writeRedactedDomainsLog(filePath){if(0===redactedDomains.length)return null;const fs=require("fs"),targetPath=filePath||"/tmp/gh-aw/redacted-urls.log",dir=require("path").dirname(targetPath);return fs.existsSync(dir)||fs.mkdirSync(dir,{recursive:!0}),fs.writeFileSync(targetPath,redactedDomains.join("\n")+"\n"),targetPath}function extractDomainsFromUrl(url){if(!url||"string"!=typeof url)return[];try{const hostname=new URL(url).hostname.toLowerCase(),domains=[hostname];return"github.com"===hostname?(domains.push("api.github.com"),domains.push("raw.githubusercontent.com"),domains.push("*.githubusercontent.com")):hostname.startsWith("api.")||(domains.push("api."+hostname),domains.push("raw."+hostname)),domains}catch(e){return[]}}function sanitizeContent(content,maxLengthOrOptions){let maxLength,allowedAliasesLowercase=[];if("number"==typeof maxLengthOrOptions?maxLength=maxLengthOrOptions:maxLengthOrOptions&&"object"==typeof maxLengthOrOptions&&(maxLength=maxLengthOrOptions.maxLength,allowedAliasesLowercase=(maxLengthOrOptions.allowedAliases||[]).map(alias=>alias.toLowerCase())),!content||"string"!=typeof content)return"";const allowedDomainsEnv=process.env.GH_AW_ALLOWED_DOMAINS;let allowedDomains=allowedDomainsEnv?allowedDomainsEnv.split(",").map(d=>d.trim()).filter(d=>d):["github.com","github.io","githubusercontent.com","githubassets.com","github.dev","codespaces.new"];const githubServerUrl=process.env.GITHUB_SERVER_URL,githubApiUrl=process.env.GITHUB_API_URL;if(githubServerUrl){const serverDomains=extractDomainsFromUrl(githubServerUrl);allowedDomains=allowedDomains.concat(serverDomains)}if(githubApiUrl){const apiDomains=extractDomainsFromUrl(githubApiUrl);allowedDomains=allowedDomains.concat(apiDomains)}allowedDomains=[...new Set(allowedDomains)];let sanitized=content;sanitized=function(s){const commandName=process.env.GH_AW_COMMAND;if(!commandName)return s;const escapedCommand=commandName.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");return s.replace(new RegExp(`^(\\s*)/(${escapedCommand})\\b`,"i"),"$1`/$2`")}(sanitized),sanitized=sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g,(_m,p1,p2)=>allowedAliasesLowercase.includes(p2.toLowerCase())?`${p1}@${p2}`:(core.info(`Escaped mention: @${p2} (not in allowed list)`),`${p1}\`@${p2}\``)),sanitized=sanitized.replace(//g,"").replace(//g,""),sanitized=function(s){const allowedTags=["b","blockquote","br","code","details","em","h1","h2","h3","h4","h5","h6","hr","i","li","ol","p","pre","strong","sub","summary","sup","table","tbody","td","th","thead","tr","ul"];return s=s.replace(//g,(match,content)=>`(![CDATA[${content.replace(/<(\/?[A-Za-z][A-Za-z0-9]*(?:[^>]*?))>/g,"($1)")}]])`),s.replace(/<(\/?[A-Za-z!][^>]*?)>/g,(match,tagContent)=>{const tagNameMatch=tagContent.match(/^\/?\s*([A-Za-z][A-Za-z0-9]*)/);if(tagNameMatch){const tagName=tagNameMatch[1].toLowerCase();if(allowedTags.includes(tagName))return match}return`(${tagContent})`})}(sanitized),sanitized=sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g,""),sanitized=sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g,""),sanitized=function(s){return s.replace(/(?&\x00-\x1f]+/g,(match,protocol)=>{if("https"===protocol.toLowerCase())return match;if(match.includes("::"))return match;if(match.includes("://")){const domainMatch=match.match(/^[^:]+:\/\/([^\/\s?#]+)/),domain=domainMatch?domainMatch[1]:match,truncated=domain.length>12?domain.substring(0,12)+"...":domain;return core.info(`Redacted URL: ${truncated}`),core.debug(`Redacted URL (full): ${match}`),redactedDomains.push(domain),"(redacted)"}if(["javascript","data","vbscript","file","about","mailto","tel","ssh","ftp"].includes(protocol.toLowerCase())){const truncated=match.length>12?match.substring(0,12)+"...":match;return core.info(`Redacted URL: ${truncated}`),core.debug(`Redacted URL (full): ${match}`),redactedDomains.push(protocol+":"),"(redacted)"}return match})}(sanitized),sanitized=function sanitizeUrlDomains(s){return s=s.replace(/\bhttps:\/\/([^\s\])}'"<>&\x00-\x1f,;]+)/gi,(match,rest)=>{const hostname=rest.split(/[\/:\?#]/)[0].toLowerCase();if(allowedDomains.some(allowedDomain=>{const normalizedAllowed=allowedDomain.toLowerCase();return hostname===normalizedAllowed||hostname.endsWith("."+normalizedAllowed)}))return match;const domain=hostname,truncated=domain.length>12?domain.substring(0,12)+"...":domain;core.info(`Redacted URL: ${truncated}`),core.debug(`Redacted URL (full): ${match}`),redactedDomains.push(domain);const urlParts=match.split(/([?])/);let result="(redacted)";for(let i=1;i65e3){const truncationMsg="\n[Content truncated due to line count]",truncatedLines=lines.slice(0,65e3).join("\n")+truncationMsg;sanitized=truncatedLines.length>maxLength?truncatedLines.substring(0,maxLength-truncationMsg.length)+truncationMsg:truncatedLines}else sanitized.length>maxLength&&(sanitized=sanitized.substring(0,maxLength)+"\n[Content truncated due to length]");return sanitized=function(s){return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi,(match,action,ref)=>`\`${action} #${ref}\``)}(sanitized),sanitized.trim()}module.exports={sanitizeContent,getRedactedDomains,clearRedactedDomains,writeRedactedDomainsLog};
\ No newline at end of file
+// @ts-check
+/**
+ * Shared sanitization utilities for GitHub Actions output
+ * This module provides functions for sanitizing content to prevent security issues
+ * and unintended side effects in GitHub Actions workflows.
+ */
+
+/**
+ * Module-level set to collect redacted URL domains across sanitization calls.
+ * @type {string[]}
+ */
+const redactedDomains = [];
+
+/**
+ * Gets the list of redacted URL domains collected during sanitization.
+ * @returns {string[]} Array of redacted domain strings
+ */
+function getRedactedDomains() {
+ return [...redactedDomains];
+}
+
+/**
+ * Clears the list of redacted URL domains.
+ * Useful for testing or resetting state between operations.
+ */
+function clearRedactedDomains() {
+ redactedDomains.length = 0;
+}
+
+/**
+ * Writes the collected redacted URL domains to a log file.
+ * Only creates the file if there are redacted domains.
+ * @param {string} [filePath] - Path to write the log file. Defaults to /tmp/gh-aw/redacted-urls.log
+ * @returns {string|null} The file path if written, null if no domains to write
+ */
+function writeRedactedDomainsLog(filePath) {
+ if (redactedDomains.length === 0) {
+ return null;
+ }
+
+ const fs = require("fs");
+ const path = require("path");
+ const targetPath = filePath || "/tmp/gh-aw/redacted-urls.log";
+
+ // Ensure directory exists
+ const dir = path.dirname(targetPath);
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+
+ // Write domains to file, one per line
+ fs.writeFileSync(targetPath, redactedDomains.join("\n") + "\n");
+
+ return targetPath;
+}
+
+/**
+ * Extract domains from a URL and return an array of domain variations
+ * @param {string} url - The URL to extract domains from
+ * @returns {string[]} Array of domain variations
+ */
+function extractDomainsFromUrl(url) {
+ if (!url || typeof url !== "string") {
+ return [];
+ }
+
+ try {
+ // Parse the URL
+ const urlObj = new URL(url);
+ const hostname = urlObj.hostname.toLowerCase();
+
+ // Return both the exact hostname and common variations
+ const domains = [hostname];
+
+ // For github.com, add api and raw content domain variations
+ if (hostname === "github.com") {
+ domains.push("api.github.com");
+ domains.push("raw.githubusercontent.com");
+ domains.push("*.githubusercontent.com");
+ }
+ // For custom GitHub Enterprise domains, add api. prefix and raw content variations
+ else if (!hostname.startsWith("api.")) {
+ domains.push("api." + hostname);
+ // For GitHub Enterprise, raw content is typically served from raw.hostname
+ domains.push("raw." + hostname);
+ }
+
+ return domains;
+ } catch (e) {
+ // Invalid URL, return empty array
+ return [];
+ }
+}
+
+/**
+ * @typedef {Object} SanitizeOptions
+ * @property {number} [maxLength] - Maximum length of content (default: 524288)
+ * @property {string[]} [allowedAliases] - List of aliases (@mentions) that should not be neutralized
+ */
+
+/**
+ * Sanitizes content for safe output in GitHub Actions
+ * @param {string} content - The content to sanitize
+ * @param {number | SanitizeOptions} [maxLengthOrOptions] - Maximum length of content (default: 524288) or options object
+ * @returns {string} The sanitized content
+ */
+function sanitizeContent(content, maxLengthOrOptions) {
+ // Handle both old signature (maxLength) and new signature (options object)
+ /** @type {number | undefined} */
+ let maxLength;
+ /** @type {string[]} */
+ let allowedAliasesLowercase = [];
+
+ if (typeof maxLengthOrOptions === "number") {
+ maxLength = maxLengthOrOptions;
+ } else if (maxLengthOrOptions && typeof maxLengthOrOptions === "object") {
+ maxLength = maxLengthOrOptions.maxLength;
+ // Pre-process allowed aliases to lowercase for efficient comparison
+ allowedAliasesLowercase = (maxLengthOrOptions.allowedAliases || []).map(alias => alias.toLowerCase());
+ }
+ if (!content || typeof content !== "string") {
+ return "";
+ }
+
+ // Read allowed domains from environment variable
+ const allowedDomainsEnv = process.env.GH_AW_ALLOWED_DOMAINS;
+ const defaultAllowedDomains = ["github.com", "github.io", "githubusercontent.com", "githubassets.com", "github.dev", "codespaces.new"];
+
+ let allowedDomains = allowedDomainsEnv
+ ? allowedDomainsEnv
+ .split(",")
+ .map(d => d.trim())
+ .filter(d => d)
+ : defaultAllowedDomains;
+
+ // Extract and add GitHub domains from GitHub context URLs
+ // This handles GitHub Enterprise deployments with custom domains
+ const githubServerUrl = process.env.GITHUB_SERVER_URL;
+ const githubApiUrl = process.env.GITHUB_API_URL;
+
+ if (githubServerUrl) {
+ const serverDomains = extractDomainsFromUrl(githubServerUrl);
+ allowedDomains = allowedDomains.concat(serverDomains);
+ }
+
+ if (githubApiUrl) {
+ const apiDomains = extractDomainsFromUrl(githubApiUrl);
+ allowedDomains = allowedDomains.concat(apiDomains);
+ }
+
+ // Remove duplicates
+ allowedDomains = [...new Set(allowedDomains)];
+
+ let sanitized = content;
+
+ // Neutralize commands at the start of text (e.g., /bot-name)
+ sanitized = neutralizeCommands(sanitized);
+
+ // Neutralize @mentions to prevent unintended notifications
+ sanitized = neutralizeMentions(sanitized);
+
+ // Remove XML comments first
+ sanitized = removeXmlComments(sanitized);
+
+ // Convert XML tags to parentheses format to prevent injection
+ sanitized = convertXmlTags(sanitized);
+
+ // Remove ANSI escape sequences
+ sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, "");
+
+ // Remove control characters (except newlines and tabs)
+ sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
+
+ // URI filtering - replace non-https protocols with "(redacted)"
+ sanitized = sanitizeUrlProtocols(sanitized);
+
+ // Domain filtering for HTTPS URIs
+ sanitized = sanitizeUrlDomains(sanitized);
+
+ // Check line count before length to provide more specific truncation message
+ const lines = sanitized.split("\n");
+ const maxLines = 65000;
+ maxLength = maxLength || 524288;
+
+ // If content has too many lines, truncate by lines (primary limit)
+ if (lines.length > maxLines) {
+ const truncationMsg = "\n[Content truncated due to line count]";
+ const truncatedLines = lines.slice(0, maxLines).join("\n") + truncationMsg;
+
+ // If still too long after line truncation, shorten but keep the line count message
+ if (truncatedLines.length > maxLength) {
+ sanitized = truncatedLines.substring(0, maxLength - truncationMsg.length) + truncationMsg;
+ } else {
+ sanitized = truncatedLines;
+ }
+ } else if (sanitized.length > maxLength) {
+ sanitized = sanitized.substring(0, maxLength) + "\n[Content truncated due to length]";
+ }
+
+ // Neutralize common bot trigger phrases
+ sanitized = neutralizeBotTriggers(sanitized);
+
+ // Trim excessive whitespace
+ return sanitized.trim();
+
+ /**
+ * Remove unknown domains
+ * @param {string} s - The string to process
+ * @returns {string} The string with unknown domains redacted
+ */
+ function sanitizeUrlDomains(s) {
+ // First pass: match all HTTPS URLs and process them
+ // We need to handle URLs that might contain other URLs in query parameters
+ s = s.replace(/\bhttps:\/\/([^\s\])}'"<>&\x00-\x1f,;]+)/gi, (match, rest) => {
+ // Extract the hostname part (before first slash, colon, or other delimiter)
+ const hostname = rest.split(/[\/:\?#]/)[0].toLowerCase();
+
+ // Check if this domain or any parent domain is in the allowlist
+ const isAllowed = allowedDomains.some(allowedDomain => {
+ const normalizedAllowed = allowedDomain.toLowerCase();
+ return hostname === normalizedAllowed || hostname.endsWith("." + normalizedAllowed);
+ });
+
+ if (isAllowed) {
+ return match; // Keep allowed URLs as-is
+ }
+
+ // Log the redaction and collect the domain
+ const domain = hostname;
+ const truncated = domain.length > 12 ? domain.substring(0, 12) + "..." : domain;
+ core.info(`Redacted URL: ${truncated}`);
+ core.debug(`Redacted URL (full): ${match}`);
+ redactedDomains.push(domain);
+
+ // For disallowed URLs, check if there are any allowed URLs in the query/fragment
+ // and preserve those while redacting the main URL
+ const urlParts = match.split(/([?])/);
+ let result = "(redacted)"; // Redact the main domain
+
+ // Process query/fragment parts to preserve any allowed URLs within them
+ for (let i = 1; i < urlParts.length; i++) {
+ if (urlParts[i].match(/^[?]$/)) {
+ result += urlParts[i]; // Keep separators
+ } else {
+ // Recursively process this part to preserve any allowed URLs
+ result += sanitizeUrlDomains(urlParts[i]);
+ }
+ }
+
+ return result;
+ });
+
+ return s;
+ }
+
+ /**
+ * Remove unknown protocols except https
+ * @param {string} s - The string to process
+ * @returns {string} The string with non-https protocols redacted
+ */
+ function sanitizeUrlProtocols(s) {
+ // Match protocol patterns but avoid command-line flags, file paths, and namespaces
+ // Protocol patterns typically have :// or are well-known schemes followed by :
+ // Use negative lookbehind to exclude patterns preceded by - (command flags)
+ // Match only patterns that look like actual protocols
+ return s.replace(/(?&\x00-\x1f]+/g, (match, protocol) => {
+ // Allow https (case insensitive), redact everything else
+ // But only if it looks like a URL (has :// or is followed by non-colon content)
+ if (protocol.toLowerCase() === "https") {
+ return match;
+ }
+
+ // Allow if it looks like a file path or namespace (::)
+ if (match.includes("::")) {
+ return match;
+ }
+
+ // Redact if it has :// (definite protocol)
+ if (match.includes("://")) {
+ // Log the redaction and collect the domain
+ // Extract domain from URL
+ const domainMatch = match.match(/^[^:]+:\/\/([^\/\s?#]+)/);
+ const domain = domainMatch ? domainMatch[1] : match;
+ const truncated = domain.length > 12 ? domain.substring(0, 12) + "..." : domain;
+ core.info(`Redacted URL: ${truncated}`);
+ core.debug(`Redacted URL (full): ${match}`);
+ redactedDomains.push(domain);
+ return "(redacted)";
+ }
+
+ // Redact well-known dangerous protocols like javascript:, data:, etc.
+ const dangerousProtocols = ["javascript", "data", "vbscript", "file", "about", "mailto", "tel", "ssh", "ftp"];
+ if (dangerousProtocols.includes(protocol.toLowerCase())) {
+ // Log the redaction and collect the domain
+ // For dangerous protocols without ://, show protocol and beginning of content
+ const truncated = match.length > 12 ? match.substring(0, 12) + "..." : match;
+ core.info(`Redacted URL: ${truncated}`);
+ core.debug(`Redacted URL (full): ${match}`);
+ redactedDomains.push(protocol + ":");
+ return "(redacted)";
+ }
+
+ // Otherwise preserve (could be file:path, namespace:thing, etc.)
+ return match;
+ });
+ }
+
+ /**
+ * Neutralizes commands at the start of text by wrapping them in backticks
+ * @param {string} s - The string to process
+ * @returns {string} The string with neutralized commands
+ */
+ function neutralizeCommands(s) {
+ const commandName = process.env.GH_AW_COMMAND;
+ if (!commandName) {
+ return s;
+ }
+
+ // Escape special regex characters in command name
+ const escapedCommand = commandName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+
+ // Neutralize /command at the start of text (with optional leading whitespace)
+ // Only match at the start of the string or after leading whitespace
+ return s.replace(new RegExp(`^(\\s*)/(${escapedCommand})\\b`, "i"), "$1`/$2`");
+ }
+
+ /**
+ * Neutralizes @mentions by wrapping them in backticks
+ * Skips mentions that are in the allowedAliases list
+ * @param {string} s - The string to process
+ * @returns {string} The string with neutralized mentions
+ */
+ function neutralizeMentions(s) {
+ // Replace @name or @org/team outside code with `@name`
+ // Skip mentions that are in the allowed aliases list
+ return s.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => {
+ // Check if this mention is in the allowed aliases list (case-insensitive)
+ // allowedAliasesLowercase is pre-processed to lowercase for efficient comparison
+ const isAllowed = allowedAliasesLowercase.includes(p2.toLowerCase());
+ if (isAllowed) {
+ return `${p1}@${p2}`; // Keep the original mention
+ }
+ // Log when a mention is escaped to help debug issues
+ core.info(`Escaped mention: @${p2} (not in allowed list)`);
+ return `${p1}\`@${p2}\``; // Neutralize the mention
+ });
+ }
+
+ /**
+ * Removes XML comments from content
+ * @param {string} s - The string to process
+ * @returns {string} The string with XML comments removed
+ */
+ function removeXmlComments(s) {
+ // Remove and malformed
+ return s.replace(//g, "").replace(//g, "");
+ }
+
+ /**
+ * Converts XML/HTML tags to parentheses format to prevent injection
+ * @param {string} s - The string to process
+ * @returns {string} The string with XML tags converted to parentheses
+ */
+ function convertXmlTags(s) {
+ // Allow safe HTML tags: b, blockquote, br, code, details, em, h1–h6, hr, i, li, ol, p, pre, strong, sub, summary, sup, table, tbody, td, th, thead, tr, ul
+ const allowedTags = ["b", "blockquote", "br", "code", "details", "em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "li", "ol", "p", "pre", "strong", "sub", "summary", "sup", "table", "tbody", "td", "th", "thead", "tr", "ul"];
+
+ // First, process CDATA sections specially - convert tags inside them and the CDATA markers
+ s = s.replace(//g, (match, content) => {
+ // Convert tags inside CDATA content
+ const convertedContent = content.replace(/<(\/?[A-Za-z][A-Za-z0-9]*(?:[^>]*?))>/g, "($1)");
+ // Return with CDATA markers also converted to parentheses
+ return `(![CDATA[${convertedContent}]])`;
+ });
+
+ // Convert opening tags: or to (tag) or (tag attr="value")
+ // Convert closing tags: to (/tag)
+ // Convert self-closing tags: or to (tag/) or (tag /)
+ // But preserve allowed safe tags
+ return s.replace(/<(\/?[A-Za-z!][^>]*?)>/g, (match, tagContent) => {
+ // Extract tag name from the content (handle closing tags and attributes)
+ const tagNameMatch = tagContent.match(/^\/?\s*([A-Za-z][A-Za-z0-9]*)/);
+ if (tagNameMatch) {
+ const tagName = tagNameMatch[1].toLowerCase();
+ if (allowedTags.includes(tagName)) {
+ return match; // Preserve allowed tags
+ }
+ }
+ return `(${tagContent})`; // Convert other tags to parentheses
+ });
+ }
+
+ /**
+ * Neutralizes bot trigger phrases by wrapping them in backticks
+ * @param {string} s - The string to process
+ * @returns {string} The string with neutralized bot triggers
+ */
+ function neutralizeBotTriggers(s) {
+ // Neutralize common bot trigger phrases like "fixes #123", "closes #asdfs", etc.
+ return s.replace(/\b(fixes?|closes?|resolves?|fix|close|resolve)\s+#(\w+)/gi, (match, action, ref) => `\`${action} #${ref}\``);
+ }
+}
+
+module.exports = { sanitizeContent, getRedactedDomains, clearRedactedDomains, writeRedactedDomainsLog };
diff --git a/pkg/workflow/js/sanitize_incoming_text.cjs b/pkg/workflow/js/sanitize_incoming_text.cjs
index 29c9f68a9eb..0874ed4a20c 100644
--- a/pkg/workflow/js/sanitize_incoming_text.cjs
+++ b/pkg/workflow/js/sanitize_incoming_text.cjs
@@ -1 +1,27 @@
-const{sanitizeContentCore,writeRedactedDomainsLog}=require("./sanitize_content_core.cjs");function sanitizeIncomingText(content,maxLength){return sanitizeContentCore(content,maxLength)}module.exports={sanitizeIncomingText,writeRedactedDomainsLog};
\ No newline at end of file
+// @ts-check
+/**
+ * Slimmed-down sanitization for incoming text (compute_text)
+ * This version does NOT include mention filtering - all @mentions are escaped
+ */
+
+const { sanitizeContentCore, writeRedactedDomainsLog } = require("./sanitize_content_core.cjs");
+
+/**
+ * Sanitizes incoming text content without selective mention filtering
+ * All @mentions are escaped to prevent unintended notifications
+ *
+ * Uses the core sanitization functions directly to minimize bundle size.
+ *
+ * @param {string} content - The content to sanitize
+ * @param {number} [maxLength] - Maximum length of content (default: 524288)
+ * @returns {string} The sanitized content with all mentions escaped
+ */
+function sanitizeIncomingText(content, maxLength) {
+ // Call core sanitization which neutralizes all mentions
+ return sanitizeContentCore(content, maxLength);
+}
+
+module.exports = {
+ sanitizeIncomingText,
+ writeRedactedDomainsLog,
+};
diff --git a/pkg/workflow/js/sanitize_label_content.cjs b/pkg/workflow/js/sanitize_label_content.cjs
index 5917117bffa..938c1994df4 100644
--- a/pkg/workflow/js/sanitize_label_content.cjs
+++ b/pkg/workflow/js/sanitize_label_content.cjs
@@ -1 +1,29 @@
-function sanitizeLabelContent(content){if(!content||"string"!=typeof content)return"";let sanitized=content.trim();return sanitized=sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g,""),sanitized=sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g,""),sanitized=sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g,(_m,p1,p2)=>`${p1}\`@${p2}\``),sanitized=sanitized.replace(/[<>&'"]/g,""),sanitized.trim()}module.exports={sanitizeLabelContent};
\ No newline at end of file
+// @ts-check
+/**
+ * Sanitize label content for GitHub API
+ * Removes control characters, ANSI codes, and neutralizes @mentions
+ * @module sanitize_label_content
+ */
+
+/**
+ * Sanitizes label content by removing control characters, ANSI escape codes,
+ * and neutralizing @mentions to prevent unintended notifications.
+ *
+ * @param {string} content - The label content to sanitize
+ * @returns {string} The sanitized label content
+ */
+function sanitizeLabelContent(content) {
+ if (!content || typeof content !== "string") {
+ return "";
+ }
+ let sanitized = content.trim();
+ // Remove ANSI escape sequences FIRST (before removing control chars)
+ sanitized = sanitized.replace(/\x1b\[[0-9;]*[mGKH]/g, "");
+ // Then remove control characters (except newlines and tabs)
+ sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
+ sanitized = sanitized.replace(/(^|[^\w`])@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:\/[A-Za-z0-9._-]+)?)/g, (_m, p1, p2) => `${p1}\`@${p2}\``);
+ sanitized = sanitized.replace(/[<>&'"]/g, "");
+ return sanitized.trim();
+}
+
+module.exports = { sanitizeLabelContent };
diff --git a/pkg/workflow/js/sanitize_label_content.test.cjs b/pkg/workflow/js/sanitize_label_content.test.cjs
index 679a8769cc4..8243247df1b 100644
--- a/pkg/workflow/js/sanitize_label_content.test.cjs
+++ b/pkg/workflow/js/sanitize_label_content.test.cjs
@@ -1 +1,94 @@
-import{describe,it,expect}from"vitest";const{sanitizeLabelContent}=require("./sanitize_label_content.cjs");describe("sanitize_label_content.cjs",()=>{describe("sanitizeLabelContent",()=>{it("should return empty string for null input",()=>{expect(sanitizeLabelContent(null)).toBe("")}),it("should return empty string for undefined input",()=>{expect(sanitizeLabelContent(void 0)).toBe("")}),it("should return empty string for non-string input",()=>{expect(sanitizeLabelContent(123)).toBe(""),expect(sanitizeLabelContent({})).toBe(""),expect(sanitizeLabelContent([])).toBe("")}),it("should trim whitespace from input",()=>{expect(sanitizeLabelContent(" test ")).toBe("test"),expect(sanitizeLabelContent("\n\ttest\n\t")).toBe("test")}),it("should remove control characters",()=>{expect(sanitizeLabelContent("test\0\blabel")).toBe("testlabel")}),it("should remove DEL character (0x7F)",()=>{expect(sanitizeLabelContent("testlabel")).toBe("testlabel")}),it("should preserve newline character",()=>{expect(sanitizeLabelContent("test\nlabel")).toBe("test\nlabel")}),it("should remove ANSI escape codes",()=>{expect(sanitizeLabelContent("[31mred text[0m")).toBe("red text")}),it("should remove various ANSI codes",()=>{expect(sanitizeLabelContent("[1;32mBold Green[0m[4mUnderline[0m")).toBe("Bold GreenUnderline")}),it("should neutralize @mentions by wrapping in backticks",()=>{expect(sanitizeLabelContent("Hello @user")).toBe("Hello `@user`"),expect(sanitizeLabelContent("@user said something")).toBe("`@user` said something")}),it("should neutralize @org/team mentions",()=>{expect(sanitizeLabelContent("Hello @myorg/myteam")).toBe("Hello `@myorg/myteam`")}),it("should not neutralize @mentions already in backticks",()=>{expect(sanitizeLabelContent("Already `@user` handled")).toBe("Already `@user` handled")}),it("should neutralize multiple @mentions",()=>{expect(sanitizeLabelContent("@user1 and @user2 are here")).toBe("`@user1` and `@user2` are here")}),it("should remove HTML special characters",()=>{expect(sanitizeLabelContent("test<>&'\"label")).toBe("testlabel")}),it("should remove less-than signs",()=>{expect(sanitizeLabelContent("a < b")).toBe("a b")}),it("should remove greater-than signs",()=>{expect(sanitizeLabelContent("a > b")).toBe("a b")}),it("should remove ampersands",()=>{expect(sanitizeLabelContent("test & label")).toBe("test label")}),it("should remove single and double quotes",()=>{expect(sanitizeLabelContent('test\'s "label"')).toBe("tests label")}),it("should handle complex input with multiple sanitizations",()=>{expect(sanitizeLabelContent(" @user [31mred[0m test&label ")).toBe("`@user` red tag testlabel")}),it("should handle empty string input",()=>{expect(sanitizeLabelContent("")).toBe("")}),it("should handle whitespace-only input",()=>{expect(sanitizeLabelContent(" \n\t ")).toBe("")}),it("should preserve normal alphanumeric characters",()=>{expect(sanitizeLabelContent("bug123")).toBe("bug123"),expect(sanitizeLabelContent("feature-request")).toBe("feature-request")}),it("should preserve hyphens and underscores",()=>{expect(sanitizeLabelContent("test-label_123")).toBe("test-label_123")}),it("should handle consecutive control characters",()=>{expect(sanitizeLabelContent("test\0label")).toBe("testlabel")}),it("should handle @mentions at various positions",()=>{expect(sanitizeLabelContent("start @user end")).toBe("start `@user` end"),expect(sanitizeLabelContent("@user at start")).toBe("`@user` at start"),expect(sanitizeLabelContent("at end @user")).toBe("at end `@user`")}),it("should not treat email-like patterns as @mentions after alphanumerics",()=>{expect(sanitizeLabelContent("email@example.com")).toBe("email@example.com")}),it("should handle username edge cases",()=>{expect(sanitizeLabelContent("@a")).toBe("`@a`"),expect(sanitizeLabelContent("@user-name-123")).toBe("`@user-name-123`")}),it("should combine all sanitization rules correctly",()=>{expect(sanitizeLabelContent(' [31m@user[0m says & "goodbye" ')).toBe("`@user` says hello goodbye")})})});
\ No newline at end of file
+import { describe, it, expect } from "vitest";
+// Import the function to test
+const { sanitizeLabelContent } = require("./sanitize_label_content.cjs");
+describe("sanitize_label_content.cjs", () => {
+ describe("sanitizeLabelContent", () => {
+ (it("should return empty string for null input", () => {
+ expect(sanitizeLabelContent(null)).toBe("");
+ }),
+ it("should return empty string for undefined input", () => {
+ expect(sanitizeLabelContent(void 0)).toBe("");
+ }),
+ it("should return empty string for non-string input", () => {
+ (expect(sanitizeLabelContent(123)).toBe(""), expect(sanitizeLabelContent({})).toBe(""), expect(sanitizeLabelContent([])).toBe(""));
+ }),
+ it("should trim whitespace from input", () => {
+ (expect(sanitizeLabelContent(" test ")).toBe("test"), expect(sanitizeLabelContent("\n\ttest\n\t")).toBe("test"));
+ }),
+ it("should remove control characters", () => {
+ expect(sanitizeLabelContent("test\0\blabel")).toBe("testlabel");
+ }),
+ it("should remove DEL character (0x7F)", () => {
+ expect(sanitizeLabelContent("testlabel")).toBe("testlabel");
+ }),
+ it("should preserve newline character", () => {
+ expect(sanitizeLabelContent("test\nlabel")).toBe("test\nlabel");
+ }),
+ it("should remove ANSI escape codes", () => {
+ expect(sanitizeLabelContent("[31mred text[0m")).toBe("red text");
+ }),
+ it("should remove various ANSI codes", () => {
+ expect(sanitizeLabelContent("[1;32mBold Green[0m[4mUnderline[0m")).toBe("Bold GreenUnderline");
+ }),
+ it("should neutralize @mentions by wrapping in backticks", () => {
+ (expect(sanitizeLabelContent("Hello @user")).toBe("Hello `@user`"), expect(sanitizeLabelContent("@user said something")).toBe("`@user` said something"));
+ }),
+ it("should neutralize @org/team mentions", () => {
+ expect(sanitizeLabelContent("Hello @myorg/myteam")).toBe("Hello `@myorg/myteam`");
+ }),
+ it("should not neutralize @mentions already in backticks", () => {
+ expect(sanitizeLabelContent("Already `@user` handled")).toBe("Already `@user` handled");
+ }),
+ it("should neutralize multiple @mentions", () => {
+ expect(sanitizeLabelContent("@user1 and @user2 are here")).toBe("`@user1` and `@user2` are here");
+ }),
+ it("should remove HTML special characters", () => {
+ expect(sanitizeLabelContent("test<>&'\"label")).toBe("testlabel");
+ }),
+ it("should remove less-than signs", () => {
+ expect(sanitizeLabelContent("a < b")).toBe("a b");
+ }),
+ it("should remove greater-than signs", () => {
+ expect(sanitizeLabelContent("a > b")).toBe("a b");
+ }),
+ it("should remove ampersands", () => {
+ expect(sanitizeLabelContent("test & label")).toBe("test label");
+ }),
+ it("should remove single and double quotes", () => {
+ expect(sanitizeLabelContent('test\'s "label"')).toBe("tests label");
+ }),
+ it("should handle complex input with multiple sanitizations", () => {
+ expect(sanitizeLabelContent(" @user [31mred[0m test&label ")).toBe("`@user` red tag testlabel");
+ }),
+ it("should handle empty string input", () => {
+ expect(sanitizeLabelContent("")).toBe("");
+ }),
+ it("should handle whitespace-only input", () => {
+ expect(sanitizeLabelContent(" \n\t ")).toBe("");
+ }),
+ it("should preserve normal alphanumeric characters", () => {
+ (expect(sanitizeLabelContent("bug123")).toBe("bug123"), expect(sanitizeLabelContent("feature-request")).toBe("feature-request"));
+ }),
+ it("should preserve hyphens and underscores", () => {
+ expect(sanitizeLabelContent("test-label_123")).toBe("test-label_123");
+ }),
+ it("should handle consecutive control characters", () => {
+ expect(sanitizeLabelContent("test\0label")).toBe("testlabel");
+ }),
+ it("should handle @mentions at various positions", () => {
+ (expect(sanitizeLabelContent("start @user end")).toBe("start `@user` end"), expect(sanitizeLabelContent("@user at start")).toBe("`@user` at start"), expect(sanitizeLabelContent("at end @user")).toBe("at end `@user`"));
+ }),
+ it("should not treat email-like patterns as @mentions after alphanumerics", () => {
+ // The regex has [^\w`] which requires non-word character before @
+ // so 'email@' won't match because 'l' is a word character
+ expect(sanitizeLabelContent("email@example.com")).toBe("email@example.com");
+ }),
+ it("should handle username edge cases", () => {
+ // Valid GitHub usernames can be 1-39 chars, alphanumeric + hyphens
+ (expect(sanitizeLabelContent("@a")).toBe("`@a`"), expect(sanitizeLabelContent("@user-name-123")).toBe("`@user-name-123`"));
+ }),
+ it("should combine all sanitization rules correctly", () => {
+ expect(sanitizeLabelContent(' [31m@user[0m says & "goodbye" ')).toBe("`@user` says hello goodbye");
+ }));
+ });
+});
diff --git a/pkg/workflow/js/sanitize_output.test.cjs b/pkg/workflow/js/sanitize_output.test.cjs
index d7194eac1d0..b828853f5d8 100644
--- a/pkg/workflow/js/sanitize_output.test.cjs
+++ b/pkg/workflow/js/sanitize_output.test.cjs
@@ -1 +1,749 @@
-import{describe,it,expect,beforeEach,afterEach,vi}from"vitest";import fs from"fs";import path from"path";const mockCore={debug:vi.fn(),info:vi.fn(),notice:vi.fn(),warning:vi.fn(),error:vi.fn(),setFailed:vi.fn(),setOutput:vi.fn(),exportVariable:vi.fn(),setSecret:vi.fn(),getInput:vi.fn(),getBooleanInput:vi.fn(),getMultilineInput:vi.fn(),getState:vi.fn(),saveState:vi.fn(),startGroup:vi.fn(),endGroup:vi.fn(),group:vi.fn(),addPath:vi.fn(),setCommandEcho:vi.fn(),isDebug:vi.fn().mockReturnValue(!1),getIDToken:vi.fn(),toPlatformPath:vi.fn(),toPosixPath:vi.fn(),toWin32Path:vi.fn(),summary:{addRaw:vi.fn().mockReturnThis(),write:vi.fn().mockResolvedValue()}};global.core=mockCore,describe("sanitize_output.cjs",()=>{let sanitizeScript,sanitizeContentFunction;beforeEach(()=>{vi.clearAllMocks(),delete process.env.GH_AW_SAFE_OUTPUTS,delete process.env.GH_AW_ALLOWED_DOMAINS;const scriptPath=path.join(process.cwd(),"sanitize_output.cjs");sanitizeScript=fs.readFileSync(scriptPath,"utf8");const scriptWithExport=sanitizeScript.replace("await main();","global.testSanitizeContent = sanitizeContent;");eval(scriptWithExport),sanitizeContentFunction=global.testSanitizeContent}),describe("sanitizeContent function",()=>{it("should handle null and undefined inputs",()=>{expect(sanitizeContentFunction(null)).toBe(""),expect(sanitizeContentFunction(void 0)).toBe(""),expect(sanitizeContentFunction("")).toBe("")}),it("should neutralize @mentions by wrapping in backticks",()=>{const result=sanitizeContentFunction("Hello @user and @org/team");expect(result).toContain("`@user`"),expect(result).toContain("`@org/team`")}),it("should not neutralize @mentions inside code blocks",()=>{const result=sanitizeContentFunction("Check `@user` in code and @realuser outside");expect(result).toContain("`@user`"),expect(result).toContain("`@realuser`")}),it("should neutralize bot trigger phrases",()=>{const result=sanitizeContentFunction("This fixes #123 and closes #456. Also resolves #789");expect(result).toContain("`fixes #123`"),expect(result).toContain("`closes #456`"),expect(result).toContain("`resolves #789`")}),it("should remove control characters except newlines and tabs",()=>{const result=sanitizeContentFunction("Hello\0world\f\nNext line\tbad");expect(result).not.toContain("\0"),expect(result).not.toContain("\f"),expect(result).not.toContain(""),expect(result).toContain("\n"),expect(result).toContain("\t")}),it("should convert XML tags to parentheses format",()=>{const result=sanitizeContentFunction('