diff --git a/.changeset/patch-add-terser-formatting.md b/.changeset/patch-add-terser-formatting.md
new file mode 100644
index 0000000000..39d9c048f2
--- /dev/null
+++ b/.changeset/patch-add-terser-formatting.md
@@ -0,0 +1,15 @@
+---
+"gh-aw": patch
+---
+
+Add a second pass for JavaScript formatting that runs `terser` on `.cjs` files after `prettier` to reduce file size while preserving readability and TypeScript compatibility.
+
+This change adds the `terser` dependency and integrates it into the `format:cjs` pipeline (prettier → terser → prettier). Files that are TypeScript-checked or contain top-level/dynamic `await` are excluded from terser processing to avoid breaking behavior.
+
+This is an internal tooling change only (formatting/minification) and does not change runtime behavior or public APIs.
+
+Summary of impact:
+- 14,784 lines removed across 65 files (~49% reduction) due to minification and formatting
+- TypeScript type checking preserved
+- All tests remain passing
+
diff --git a/.github/workflows/go-file-size-reduction-project64.campaign.g.lock.yml b/.github/workflows/go-file-size-reduction-project64.campaign.g.lock.yml
index bef2ab45c6..36ab6dfa37 100644
--- a/.github/workflows/go-file-size-reduction-project64.campaign.g.lock.yml
+++ b/.github/workflows/go-file-size-reduction-project64.campaign.g.lock.yml
@@ -19,82 +19,7 @@
# gh aw compile
# For more information: https://github.com/githubnext/gh-aw/blob/main/.github/aw/github-agentic-workflows.md
#
-#
# Systematically reduce oversized Go files to improve maintainability. Success: all files ≤800 LOC, maintain coverage, no regressions.
-#
-# Original Frontmatter:
-# ```yaml
-# name: "Go File Size Reduction Campaign (Project 64)"
-# description: "Systematically reduce oversized Go files to improve maintainability. Success: all files ≤800 LOC, maintain coverage, no regressions."
-# on:
-# schedule:
-# - cron: "0 18 * * *"
-# workflow_dispatch:
-# engine: copilot
-# safe-outputs:
-# add-comment:
-# max: 10
-# update-project:
-# max: 10
-# runs-on: ubuntu-latest
-# roles:
-# - "admin"
-# - "maintainer"
-# - "write"
-# ```
-#
-# Job Dependency Graph:
-# ```mermaid
-# graph LR
-# activation["activation"]
-# add_comment["add_comment"]
-# agent["agent"]
-# conclusion["conclusion"]
-# detection["detection"]
-# update_project["update_project"]
-# activation --> agent
-# activation --> conclusion
-# add_comment --> conclusion
-# agent --> add_comment
-# agent --> conclusion
-# agent --> detection
-# agent --> update_project
-# detection --> add_comment
-# detection --> conclusion
-# detection --> update_project
-# update_project --> conclusion
-# ```
-#
-# Original Prompt:
-# ```markdown
-# # Campaign Orchestrator
-#
-# This workflow orchestrates the 'Go File Size Reduction Campaign (Project 64)' campaign.
-#
-# - Tracker label: `campaign:go-file-size-reduction-project64`
-# - Associated workflows: daily-file-diet
-# - Memory paths: memory/campaigns/go-file-size-reduction-project64-*/**
-# - Metrics glob: `memory/campaigns/go-file-size-reduction-project64-*/metrics/*.json`
-# - Project URL: https://github.com/orgs/githubnext/projects/64
-#
-# Each time this orchestrator runs on its daily schedule (or when manually dispatched), generate a concise status report for this campaign. Summarize current metrics and update any tracker issues using the campaign label.
-#
-# If all issues with the campaign label are closed, the campaign is complete. This is a normal terminal state indicating successful completion, not a blocker or error. When the campaign is complete, mark the project as finished and take no further action. Do not report closed issues as blockers.
-#
-# Keep the campaign Project dashboard in sync using the `update-project` safe output. When calling update-project, use the `project` field with this exact URL: https://github.com/orgs/githubnext/projects/64
-#
-# Use these details to coordinate workers, update metrics, and track progress for this campaign.
-# ```
-#
-# Pinned GitHub Actions:
-# - actions/checkout@v5 (93cb6efe18208431cddfb8368fd83d5badbf9bfd)
-# https://github.com/actions/checkout/commit/93cb6efe18208431cddfb8368fd83d5badbf9bfd
-# - actions/download-artifact@v6 (018cc2cf5baa6db3ef3c5f8a56943fffe632ef53)
-# https://github.com/actions/download-artifact/commit/018cc2cf5baa6db3ef3c5f8a56943fffe632ef53
-# - actions/github-script@v8 (ed597411d8f924073f98dfc5c65a23a2325f34cd)
-# https://github.com/actions/github-script/commit/ed597411d8f924073f98dfc5c65a23a2325f34cd
-# - actions/upload-artifact@v5 (330a01c490aca151604b8cf639adc76d48f6c5d4)
-# https://github.com/actions/upload-artifact/commit/330a01c490aca151604b8cf639adc76d48f6c5d4
name: "Go File Size Reduction Campaign (Project 64)"
"on":
@@ -188,9 +113,7 @@ jobs:
.addRaw("**Files:**\n")
.addRaw(`- Source: \`${workflowMdPath}\`\n`)
.addRaw(` - Last commit: ${workflowTimestamp}\n`)
- .addRaw(
- ` - Commit SHA: [\`${workflowCommit.sha.substring(0, 7)}\`](https://github.com/${owner}/${repo}/commit/${workflowCommit.sha})\n`
- )
+ .addRaw(` - Commit SHA: [\`${workflowCommit.sha.substring(0, 7)}\`](https://github.com/${owner}/${repo}/commit/${workflowCommit.sha})\n`)
.addRaw(`- Lock: \`${lockFilePath}\`\n`)
.addRaw(` - Last commit: ${lockTimestamp}\n`)
.addRaw(` - Commit SHA: [\`${lockCommit.sha.substring(0, 7)}\`](https://github.com/${owner}/${repo}/commit/${lockCommit.sha})\n\n`)
@@ -340,11 +263,8 @@ jobs:
}
const messages = getMessages();
const templateContext = toSnakeCase(ctx);
- const defaultInstall =
- "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!";
- return messages?.footerInstall
- ? renderTemplate(messages.footerInstall, templateContext)
- : renderTemplate(defaultInstall, templateContext);
+ const defaultInstall = "> Arr! To plunder this workflow fer yer own ship, run `gh aw add {workflow_source}`. Chart yer course at [🦜 {workflow_source_url}]({workflow_source_url})!";
+ return messages?.footerInstall ? renderTemplate(messages.footerInstall, templateContext) : renderTemplate(defaultInstall, templateContext);
}
function generateXMLMarker(workflowName, runUrl) {
const engineId = process.env.GH_AW_ENGINE_ID || "";
@@ -368,15 +288,7 @@ jobs:
parts.push(`run: ${runUrl}`);
return ``;
}
- function generateFooterWithMessages(
- workflowName,
- runUrl,
- workflowSource,
- workflowSourceURL,
- triggeringIssueNumber,
- triggeringPRNumber,
- triggeringDiscussionNumber
- ) {
+ function generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber) {
let triggeringNumber;
if (triggeringIssueNumber) {
triggeringNumber = triggeringIssueNumber;
@@ -723,10 +635,7 @@ jobs:
const commentTarget = process.env.GH_AW_COMMENT_TARGET || "triggering";
core.info(`Comment target configuration: ${commentTarget}`);
const isIssueContext = context.eventName === "issues" || context.eventName === "issue_comment";
- const isPRContext =
- context.eventName === "pull_request" ||
- context.eventName === "pull_request_review" ||
- context.eventName === "pull_request_review_comment";
+ const isPRContext = context.eventName === "pull_request" || context.eventName === "pull_request_review" || context.eventName === "pull_request_review_comment";
const isDiscussionContext = context.eventName === "discussion" || context.eventName === "discussion_comment";
const isDiscussion = isDiscussionContext || isDiscussionExplicit;
const workflowId = process.env.GITHUB_WORKFLOW || "";
@@ -795,10 +704,8 @@ jobs:
core.info('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation');
return;
}
- const triggeringIssueNumber =
- context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined;
- const triggeringPRNumber =
- context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined);
+ const triggeringIssueNumber = context.payload?.issue?.number && !context.payload?.issue?.pull_request ? context.payload.issue.number : undefined;
+ const triggeringPRNumber = context.payload?.pull_request?.number || (context.payload?.issue?.pull_request ? context.payload.issue.number : undefined);
const triggeringDiscussionNumber = context.payload?.discussion?.number;
const createdComments = [];
for (let i = 0; i < commentItems.length; i++) {
@@ -886,9 +793,7 @@ jobs:
const workflowSourceURL = process.env.GH_AW_WORKFLOW_SOURCE_URL || "";
const runId = context.runId;
const githubServer = process.env.GITHUB_SERVER_URL || "https://github.com";
- const runUrl = context.payload.repository
- ? `${context.payload.repository.html_url}/actions/runs/${runId}`
- : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
+ const runUrl = context.payload.repository ? `${context.payload.repository.html_url}/actions/runs/${runId}` : `${githubServer}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
if (workflowId) {
body += `\n\n`;
}
@@ -897,28 +802,11 @@ jobs:
body += trackerIDComment;
}
body += `\n\n`;
- body += generateFooterWithMessages(
- workflowName,
- runUrl,
- workflowSource,
- workflowSourceURL,
- triggeringIssueNumber,
- triggeringPRNumber,
- triggeringDiscussionNumber
- );
+ body += generateFooterWithMessages(workflowName, runUrl, workflowSource, workflowSourceURL, triggeringIssueNumber, triggeringPRNumber, triggeringDiscussionNumber);
try {
if (hideOlderCommentsEnabled && workflowId) {
core.info("Hide-older-comments is enabled, searching for previous comments to hide");
- await hideOlderComments(
- github,
- context.repo.owner,
- context.repo.repo,
- itemNumber,
- workflowId,
- commentEndpoint === "discussions",
- "outdated",
- allowedReasons
- );
+ await hideOlderComments(github, context.repo.owner, context.repo.repo, itemNumber, workflowId, commentEndpoint === "discussions", "outdated", allowedReasons);
}
let comment;
if (commentEndpoint === "discussions") {
@@ -1484,9 +1372,7 @@ jobs:
server.debug(` [${toolName}] Python handler args: ${JSON.stringify(args)}`);
server.debug(` [${toolName}] Timeout: ${timeoutSeconds}s`);
const inputJson = JSON.stringify(args || {});
- server.debug(
- ` [${toolName}] Input JSON (${inputJson.length} bytes): ${inputJson.substring(0, 200)}${inputJson.length > 200 ? "..." : ""}`
- );
+ server.debug(` [${toolName}] Input JSON (${inputJson.length} bytes): ${inputJson.substring(0, 200)}${inputJson.length > 200 ? "..." : ""}`);
return new Promise((resolve, reject) => {
server.debug(` [${toolName}] Executing Python script...`);
const child = execFile(
@@ -1594,9 +1480,7 @@ jobs:
try {
if (fs.existsSync(outputFile)) {
const outputContent = fs.readFileSync(outputFile, "utf-8");
- server.debug(
- ` [${toolName}] Output file content: ${outputContent.substring(0, 500)}${outputContent.length > 500 ? "..." : ""}`
- );
+ server.debug(` [${toolName}] Output file content: ${outputContent.substring(0, 500)}${outputContent.length > 500 ? "..." : ""}`);
const lines = outputContent.split("\n");
for (const line of lines) {
const trimmed = line.trim();
@@ -1654,10 +1538,7 @@ jobs:
fs.mkdirSync(server.logDir, { recursive: true });
}
const timestamp = new Date().toISOString();
- fs.writeFileSync(
- server.logFilePath,
- `# ${server.serverInfo.name} MCP Server Log\n# Started: ${timestamp}\n# Version: ${server.serverInfo.version}\n\n`
- );
+ fs.writeFileSync(server.logFilePath, `# ${server.serverInfo.name} MCP Server Log\n# Started: ${timestamp}\n# Version: ${server.serverInfo.version}\n\n`);
server.logFileInitialized = true;
} catch {
}
@@ -2317,10 +2198,7 @@ jobs:
const isInWorkspace = absolutePath.startsWith(path.resolve(workspaceDir));
const isInTmp = absolutePath.startsWith(tmpDir);
if (!isInWorkspace && !isInTmp) {
- throw new Error(
- `File path must be within workspace directory (${workspaceDir}) or /tmp directory. ` +
- `Provided path: ${filePath} (resolved to: ${absolutePath})`
- );
+ throw new Error(`File path must be within workspace directory (${workspaceDir}) or /tmp directory. ` + `Provided path: ${filePath} (resolved to: ${absolutePath})`);
}
if (!fs.existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`);
@@ -2579,10 +2457,7 @@ jobs:
};
const entryJSON = JSON.stringify(entry);
fs.appendFileSync(outputFile, entryJSON + "\n");
- const outputText =
- jobConfig && jobConfig.output
- ? jobConfig.output
- : `Safe-job '${configKey}' executed successfully with arguments: ${JSON.stringify(args)}`;
+ const outputText = jobConfig && jobConfig.output ? jobConfig.output : `Safe-job '${configKey}' executed successfully with arguments: ${JSON.stringify(args)}`;
return {
content: [
{
@@ -3098,16 +2973,13 @@ jobs:
return result;
}
function renderMarkdownTemplate(markdown) {
- let result = markdown.replace(
- /(\n?)([ \t]*{{#if\s+([^}]*)}}[ \t]*\n)([\s\S]*?)([ \t]*{{\/if}}[ \t]*)(\n?)/g,
- (match, leadNL, openLine, cond, body, closeLine, trailNL) => {
- if (isTruthy(cond)) {
- return leadNL + body;
- } else {
- return "";
- }
+ let result = markdown.replace(/(\n?)([ \t]*{{#if\s+([^}]*)}}[ \t]*\n)([\s\S]*?)([ \t]*{{\/if}}[ \t]*)(\n?)/g, (match, leadNL, openLine, cond, body, closeLine, trailNL) => {
+ if (isTruthy(cond)) {
+ return leadNL + body;
+ } else {
+ return "";
}
- );
+ });
result = result.replace(/{{#if\s+([^}]*)}}([\s\S]*?){{\/if}}/g, (_, cond, body) => (isTruthy(cond) ? body : ""));
result = result.replace(/\n{3,}/g, "\n\n");
return result;
@@ -3477,36 +3349,33 @@ jobs:
});
}
function sanitizeUrlProtocols(s) {
- return s.replace(
- /((?:http|ftp|file|ssh|git):\/\/([\w.-]*)(?:[^\s]*)|(?:data|javascript|vbscript|about|mailto|tel):[^\s]+)/gi,
- (match, _fullMatch, domain) => {
- if (domain) {
- const domainLower = domain.toLowerCase();
- const truncated = domainLower.length > 12 ? domainLower.substring(0, 12) + "..." : domainLower;
+ return s.replace(/((?:http|ftp|file|ssh|git):\/\/([\w.-]*)(?:[^\s]*)|(?:data|javascript|vbscript|about|mailto|tel):[^\s]+)/gi, (match, _fullMatch, domain) => {
+ if (domain) {
+ const domainLower = domain.toLowerCase();
+ const truncated = domainLower.length > 12 ? domainLower.substring(0, 12) + "..." : domainLower;
+ if (typeof core !== "undefined" && core.info) {
+ core.info(`Redacted URL: ${truncated}`);
+ }
+ if (typeof core !== "undefined" && core.debug) {
+ core.debug(`Redacted URL (full): ${match}`);
+ }
+ addRedactedDomain(domainLower);
+ } else {
+ const protocolMatch = match.match(/^([^:]+):/);
+ if (protocolMatch) {
+ const protocol = protocolMatch[1] + ":";
+ const truncated = match.length > 12 ? match.substring(0, 12) + "..." : match;
if (typeof core !== "undefined" && core.info) {
core.info(`Redacted URL: ${truncated}`);
}
if (typeof core !== "undefined" && core.debug) {
core.debug(`Redacted URL (full): ${match}`);
}
- addRedactedDomain(domainLower);
- } else {
- const protocolMatch = match.match(/^([^:]+):/);
- if (protocolMatch) {
- const protocol = protocolMatch[1] + ":";
- const truncated = match.length > 12 ? match.substring(0, 12) + "..." : match;
- if (typeof core !== "undefined" && core.info) {
- core.info(`Redacted URL: ${truncated}`);
- }
- if (typeof core !== "undefined" && core.debug) {
- core.debug(`Redacted URL (full): ${match}`);
- }
- addRedactedDomain(protocol);
- }
+ addRedactedDomain(protocol);
}
- return "(redacted)";
}
- );
+ return "(redacted)";
+ });
}
function neutralizeCommands(s) {
const commandName = process.env.GH_AW_COMMAND;
@@ -3528,37 +3397,7 @@ jobs:
return s.replace(//g, "").replace(//g, "");
}
function convertXmlTags(s) {
- const allowedTags = [
- "b",
- "blockquote",
- "br",
- "code",
- "details",
- "em",
- "h1",
- "h2",
- "h3",
- "h4",
- "h5",
- "h6",
- "hr",
- "i",
- "li",
- "ol",
- "p",
- "pre",
- "strong",
- "sub",
- "summary",
- "sup",
- "table",
- "tbody",
- "td",
- "th",
- "thead",
- "tr",
- "ul",
- ];
+ const allowedTags = ["b", "blockquote", "br", "code", "details", "em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "li", "ol", "p", "pre", "strong", "sub", "summary", "sup", "table", "tbody", "td", "th", "thead", "tr", "ul"];
s = s.replace(//g, (match, content) => {
const convertedContent = content.replace(/<(\/?[A-Za-z][A-Za-z0-9]*(?:[^>]*?))>/g, "($1)");
return `(![CDATA[${convertedContent}]])`;
@@ -3670,36 +3509,33 @@ jobs:
return result;
}
function sanitizeUrlProtocols(s) {
- return s.replace(
- /\b((?:http|ftp|file|ssh|git):\/\/([\w.-]+)(?:[^\s]*)|(?:data|javascript|vbscript|about|mailto|tel):[^\s]+)/gi,
- (match, _fullMatch, domain) => {
- if (domain) {
- const domainLower = domain.toLowerCase();
- const truncated = domainLower.length > 12 ? domainLower.substring(0, 12) + "..." : domainLower;
+ return s.replace(/\b((?:http|ftp|file|ssh|git):\/\/([\w.-]+)(?:[^\s]*)|(?:data|javascript|vbscript|about|mailto|tel):[^\s]+)/gi, (match, _fullMatch, domain) => {
+ if (domain) {
+ const domainLower = domain.toLowerCase();
+ const truncated = domainLower.length > 12 ? domainLower.substring(0, 12) + "..." : domainLower;
+ if (typeof core !== "undefined" && core.info) {
+ core.info(`Redacted URL: ${truncated}`);
+ }
+ if (typeof core !== "undefined" && core.debug) {
+ core.debug(`Redacted URL (full): ${match}`);
+ }
+ addRedactedDomain(domainLower);
+ } else {
+ const protocolMatch = match.match(/^([^:]+):/);
+ if (protocolMatch) {
+ const protocol = protocolMatch[1] + ":";
+ const truncated = match.length > 12 ? match.substring(0, 12) + "..." : match;
if (typeof core !== "undefined" && core.info) {
core.info(`Redacted URL: ${truncated}`);
}
if (typeof core !== "undefined" && core.debug) {
core.debug(`Redacted URL (full): ${match}`);
}
- addRedactedDomain(domainLower);
- } else {
- const protocolMatch = match.match(/^([^:]+):/);
- if (protocolMatch) {
- const protocol = protocolMatch[1] + ":";
- const truncated = match.length > 12 ? match.substring(0, 12) + "..." : match;
- if (typeof core !== "undefined" && core.info) {
- core.info(`Redacted URL: ${truncated}`);
- }
- if (typeof core !== "undefined" && core.debug) {
- core.debug(`Redacted URL (full): ${match}`);
- }
- addRedactedDomain(protocol);
- }
+ addRedactedDomain(protocol);
}
- return "(redacted)";
}
- );
+ return "(redacted)";
+ });
}
function neutralizeCommands(s) {
const commandName = process.env.GH_AW_COMMAND;
@@ -3725,37 +3561,7 @@ jobs:
return s.replace(//g, "").replace(//g, "");
}
function convertXmlTags(s) {
- const allowedTags = [
- "b",
- "blockquote",
- "br",
- "code",
- "details",
- "em",
- "h1",
- "h2",
- "h3",
- "h4",
- "h5",
- "h6",
- "hr",
- "i",
- "li",
- "ol",
- "p",
- "pre",
- "strong",
- "sub",
- "summary",
- "sup",
- "table",
- "tbody",
- "td",
- "th",
- "thead",
- "tr",
- "ul",
- ];
+ const allowedTags = ["b", "blockquote", "br", "code", "details", "em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "li", "ol", "p", "pre", "strong", "sub", "summary", "sup", "table", "tbody", "td", "th", "thead", "tr", "ul"];
s = s.replace(//g, (match, content) => {
const convertedContent = content.replace(/<(\/?[A-Za-z][A-Za-z0-9]*(?:[^>]*?))>/g, "($1)");
return `(![CDATA[${convertedContent}]])`;
@@ -4447,9 +4253,7 @@ jobs:
core.info(`Loaded validation config from ${validationConfigPath}`);
}
} catch (error) {
- core.warning(
- `Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}`
- );
+ core.warning(`Failed to read validation config from ${validationConfigPath}: ${error instanceof Error ? error.message : String(error)}`);
}
const mentionsConfig = validationConfig?.mentions || null;
const allowedMentions = await resolveAllowedMentionsFromPayload(context, github, core, mentionsConfig);
@@ -4662,9 +4466,7 @@ jobs:
core.info(`[INGESTION] Line ${i + 1}: Original type='${originalType}', Normalized type='${itemType}'`);
item.type = itemType;
if (!expectedOutputTypes[itemType]) {
- core.warning(
- `[INGESTION] Line ${i + 1}: Type '${itemType}' not found in expected types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`
- );
+ core.warning(`[INGESTION] Line ${i + 1}: Type '${itemType}' not found in expected types: ${JSON.stringify(Object.keys(expectedOutputTypes))}`);
errors.push(`Line ${i + 1}: Unexpected output type '${itemType}'. Expected one of: ${Object.keys(expectedOutputTypes).join(", ")}`);
continue;
}
@@ -4746,10 +4548,7 @@ jobs:
core.info(`Patch file ${hasPatch ? "exists" : "does not exist"} at: ${patchPath}`);
let allowEmptyPR = false;
if (safeOutputsConfig) {
- if (
- safeOutputsConfig["create-pull-request"]?.["allow-empty"] === true ||
- safeOutputsConfig["create_pull_request"]?.["allow_empty"] === true
- ) {
+ if (safeOutputsConfig["create-pull-request"]?.["allow-empty"] === true || safeOutputsConfig["create_pull_request"]?.["allow_empty"] === true) {
allowEmptyPR = true;
core.info(`allow-empty is enabled for create-pull-request`);
}
@@ -5120,24 +4919,7 @@ jobs:
"Custom Agents": [],
Other: [],
};
- const builtinTools = [
- "bash",
- "write_bash",
- "read_bash",
- "stop_bash",
- "list_bash",
- "grep",
- "glob",
- "view",
- "create",
- "edit",
- "store_memory",
- "code_review",
- "codeql_checker",
- "report_progress",
- "report_intent",
- "gh-advisory-database",
- ];
+ const builtinTools = ["bash", "write_bash", "read_bash", "stop_bash", "list_bash", "grep", "glob", "view", "create", "edit", "store_memory", "code_review", "codeql_checker", "report_progress", "report_intent", "gh-advisory-database"];
const internalTools = ["fetch_copilot_cli_documentation"];
for (const tool of initEntry.tools) {
const toolLower = tool.toLowerCase();
@@ -5533,9 +5315,7 @@ jobs:
const cacheCreationTokens = usage.cache_creation_input_tokens || 0;
const cacheReadTokens = usage.cache_read_input_tokens || 0;
const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens;
- lines.push(
- ` Tokens: ${totalTokens.toLocaleString()} total (${usage.input_tokens.toLocaleString()} in / ${usage.output_tokens.toLocaleString()} out)`
- );
+ lines.push(` Tokens: ${totalTokens.toLocaleString()} total (${usage.input_tokens.toLocaleString()} in / ${usage.output_tokens.toLocaleString()} out)`);
}
}
if (lastEntry?.total_cost_usd) {
@@ -5694,9 +5474,7 @@ jobs:
const cacheCreationTokens = usage.cache_creation_input_tokens || 0;
const cacheReadTokens = usage.cache_read_input_tokens || 0;
const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens;
- lines.push(
- ` Tokens: ${totalTokens.toLocaleString()} total (${usage.input_tokens.toLocaleString()} in / ${usage.output_tokens.toLocaleString()} out)`
- );
+ lines.push(` Tokens: ${totalTokens.toLocaleString()} total (${usage.input_tokens.toLocaleString()} in / ${usage.output_tokens.toLocaleString()} out)`);
}
}
if (lastEntry?.total_cost_usd) {
@@ -5797,11 +5575,7 @@ jobs:
});
}
function extractPremiumRequestCount(logContent) {
- const patterns = [
- /premium\s+requests?\s+consumed:?\s*(\d+)/i,
- /(\d+)\s+premium\s+requests?\s+consumed/i,
- /consumed\s+(\d+)\s+premium\s+requests?/i,
- ];
+ const patterns = [/premium\s+requests?\s+consumed:?\s*(\d+)/i, /(\d+)\s+premium\s+requests?\s+consumed/i, /consumed\s+(\d+)\s+premium\s+requests?/i];
for (const pattern of patterns) {
const match = logContent.match(pattern);
if (match && match[1]) {
@@ -5873,8 +5647,7 @@ jobs:
const initEntry = logEntries.find(entry => entry.type === "system" && entry.subtype === "init");
markdown += generateInformationSection(lastEntry, {
additionalInfoCallback: entry => {
- const isPremiumModel =
- initEntry && initEntry.model_info && initEntry.model_info.billing && initEntry.model_info.billing.is_premium === true;
+ const isPremiumModel = initEntry && initEntry.model_info && initEntry.model_info.billing && initEntry.model_info.billing.is_premium === true;
if (isPremiumModel) {
const premiumRequestCount = extractPremiumRequestCount(logContent);
return `**Premium Requests Consumed:** ${premiumRequestCount}\n\n`;
@@ -6301,299 +6074,151 @@ jobs:
with:
script: |
function sanitizeWorkflowName(name) {
-
return name
-
.toLowerCase()
-
.replace(/[:\\/\s]/g, "-")
-
.replace(/[^a-z0-9._-]/g, "-");
-
}
-
function main() {
-
const fs = require("fs");
-
const path = require("path");
-
try {
-
const squidLogsDir = `/tmp/gh-aw/sandbox/firewall/logs/`;
-
if (!fs.existsSync(squidLogsDir)) {
-
core.info(`No firewall logs directory found at: ${squidLogsDir}`);
-
return;
-
}
-
const files = fs.readdirSync(squidLogsDir).filter(file => file.endsWith(".log"));
-
if (files.length === 0) {
-
core.info(`No firewall log files found in: ${squidLogsDir}`);
-
return;
-
}
-
core.info(`Found ${files.length} firewall log file(s)`);
-
let totalRequests = 0;
-
let allowedRequests = 0;
-
let deniedRequests = 0;
-
const allowedDomains = new Set();
-
const deniedDomains = new Set();
-
const requestsByDomain = new Map();
-
for (const file of files) {
-
const filePath = path.join(squidLogsDir, file);
-
core.info(`Parsing firewall log: ${file}`);
-
const content = fs.readFileSync(filePath, "utf8");
-
const lines = content.split("\n").filter(line => line.trim());
-
for (const line of lines) {
-
const entry = parseFirewallLogLine(line);
-
if (!entry) {
-
continue;
-
}
-
totalRequests++;
-
const isAllowed = isRequestAllowed(entry.decision, entry.status);
-
if (isAllowed) {
-
allowedRequests++;
-
allowedDomains.add(entry.domain);
-
} else {
-
deniedRequests++;
-
deniedDomains.add(entry.domain);
-
}
-
if (!requestsByDomain.has(entry.domain)) {
-
requestsByDomain.set(entry.domain, { allowed: 0, denied: 0 });
-
}
-
const domainStats = requestsByDomain.get(entry.domain);
-
if (isAllowed) {
-
domainStats.allowed++;
-
} else {
-
domainStats.denied++;
-
}
-
}
-
}
-
const summary = generateFirewallSummary({
-
totalRequests,
-
allowedRequests,
-
deniedRequests,
-
allowedDomains: Array.from(allowedDomains).sort(),
-
deniedDomains: Array.from(deniedDomains).sort(),
-
requestsByDomain,
-
});
-
core.summary.addRaw(summary).write();
-
core.info("Firewall log summary generated successfully");
-
} catch (error) {
-
core.setFailed(error instanceof Error ? error : String(error));
-
}
-
}
-
function parseFirewallLogLine(line) {
-
const trimmed = line.trim();
-
if (!trimmed || trimmed.startsWith("#")) {
-
return null;
-
}
-
const fields = trimmed.match(/(?:[^\s"]+|"[^"]*")+/g);
-
if (!fields || fields.length < 10) {
-
return null;
-
}
-
const timestamp = fields[0];
-
if (!/^\d+(\.\d+)?$/.test(timestamp)) {
-
return null;
-
}
-
return {
-
timestamp,
-
clientIpPort: fields[1],
-
domain: fields[2],
-
destIpPort: fields[3],
-
proto: fields[4],
-
method: fields[5],
-
status: fields[6],
-
decision: fields[7],
-
url: fields[8],
-
userAgent: fields[9]?.replace(/^"|"$/g, "") || "-",
-
};
-
}
-
function isRequestAllowed(decision, status) {
-
const statusCode = parseInt(status, 10);
-
if (statusCode === 200 || statusCode === 206 || statusCode === 304) {
-
return true;
-
}
-
if (decision.includes("TCP_TUNNEL") || decision.includes("TCP_HIT") || decision.includes("TCP_MISS")) {
-
return true;
-
}
-
if (decision.includes("NONE_NONE") || decision.includes("TCP_DENIED") || statusCode === 403 || statusCode === 407) {
-
return false;
-
}
-
return false;
-
}
-
function generateFirewallSummary(analysis) {
-
const { totalRequests, requestsByDomain } = analysis;
-
const validDomains = Array.from(requestsByDomain.keys())
-
.filter(domain => domain !== "-")
-
.sort();
-
const uniqueDomainCount = validDomains.length;
-
let validAllowedRequests = 0;
-
let validDeniedRequests = 0;
-
for (const domain of validDomains) {
-
const stats = requestsByDomain.get(domain);
-
validAllowedRequests += stats.allowed;
-
validDeniedRequests += stats.denied;
-
}
-
let summary = "### 🔥 Firewall Activity\n\n";
-
summary += "\n";
-
summary += `📊 ${totalRequests} request${totalRequests !== 1 ? "s" : ""} | `;
-
summary += `${validAllowedRequests} allowed | `;
-
summary += `${validDeniedRequests} blocked | `;
-
summary += `${uniqueDomainCount} unique domain${uniqueDomainCount !== 1 ? "s" : ""}
\n\n`;
-
if (uniqueDomainCount > 0) {
-
summary += "| Domain | Allowed | Denied |\n";
-
summary += "|--------|---------|--------|\n";
-
for (const domain of validDomains) {
-
const stats = requestsByDomain.get(domain);
-
summary += `| ${domain} | ${stats.allowed} | ${stats.denied} |\n`;
-
}
-
} else {
-
summary += "No firewall activity detected.\n";
-
}
-
summary += "\n \n\n";
-
return summary;
-
}
-
- const isDirectExecution =
-
- typeof module === "undefined" || (typeof require !== "undefined" && typeof require.main !== "undefined" && require.main === module);
-
+ const isDirectExecution = typeof module === "undefined" || (typeof require !== "undefined" && typeof require.main !== "undefined" && require.main === module);
if (isDirectExecution) {
-
main();
-
}
-
- name: Upload Agent Stdio
if: always()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
@@ -6748,9 +6373,7 @@ jobs:
}
lastIndex = regex.lastIndex;
if (iterationCount === ITERATION_WARNING_THRESHOLD) {
- core.warning(
- `High iteration count (${iterationCount}) on line ${lineIndex + 1} with pattern: ${pattern.description || pattern.pattern}`
- );
+ core.warning(`High iteration count (${iterationCount}) on line ${lineIndex + 1} with pattern: ${pattern.description || pattern.pattern}`);
core.warning(`Line content (truncated): ${truncateString(line, 200)}`);
}
if (iterationCount > MAX_ITERATIONS_PER_LINE) {
@@ -7057,9 +6680,7 @@ jobs:
core.setOutput("total_count", missingTools.length.toString());
if (missingTools.length > 0) {
core.info("Missing tools summary:");
- core.summary
- .addHeading("Missing Tools Report", 3)
- .addRaw(`Found **${missingTools.length}** missing tool${missingTools.length > 1 ? "s" : ""} in this workflow execution.\n\n`);
+ core.summary.addHeading("Missing Tools Report", 3).addRaw(`Found **${missingTools.length}** missing tool${missingTools.length > 1 ? "s" : ""} in this workflow execution.\n\n`);
missingTools.forEach((tool, index) => {
core.info(`${index + 1}. Tool: ${tool.tool}`);
core.info(` Reason: ${tool.reason}`);
@@ -7192,9 +6813,7 @@ jobs:
const messages = getMessages();
const templateContext = toSnakeCase(ctx);
const defaultMessage = "⚠️ Security scanning failed for [{workflow_name}]({run_url}). Review the logs for details.";
- return messages?.detectionFailure
- ? renderTemplate(messages.detectionFailure, templateContext)
- : renderTemplate(defaultMessage, templateContext);
+ return messages?.detectionFailure ? renderTemplate(messages.detectionFailure, templateContext) : renderTemplate(defaultMessage, templateContext);
}
function collectGeneratedAssets() {
const assets = [];
@@ -7722,29 +7341,21 @@ jobs:
}
function parseProjectInput(projectUrl) {
if (!projectUrl || typeof projectUrl !== "string") {
- throw new Error(
- `Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`
- );
+ throw new Error(`Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`);
}
const urlMatch = projectUrl.match(/github\.com\/(?:users|orgs)\/[^/]+\/projects\/(\d+)/);
if (!urlMatch) {
- throw new Error(
- `Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`
- );
+ throw new Error(`Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`);
}
return urlMatch[1];
}
function parseProjectUrl(projectUrl) {
if (!projectUrl || typeof projectUrl !== "string") {
- throw new Error(
- `Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`
- );
+ throw new Error(`Invalid project input: expected string, got ${typeof projectUrl}. The "project" field is required and must be a full GitHub project URL.`);
}
const match = projectUrl.match(/github\.com\/(users|orgs)\/([^/]+)\/projects\/(\d+)/);
if (!match) {
- throw new Error(
- `Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`
- );
+ throw new Error(`Invalid project URL: "${projectUrl}". The "project" field must be a full GitHub project URL (e.g., https://github.com/orgs/myorg/projects/123).`);
}
return { scope: match[1], ownerLogin: match[2], projectNumber: match[3] };
}
@@ -7955,9 +7566,7 @@ jobs:
} catch (viewerError) {
core.warning(`Could not resolve token identity (viewer.login): ${viewerError.message}`);
}
- core.info(
- `[2/5] Resolving project from URL (scope=${projectInfo.scope}, login=${projectInfo.ownerLogin}, number=${projectNumberFromUrl})...`
- );
+ core.info(`[2/5] Resolving project from URL (scope=${projectInfo.scope}, login=${projectInfo.ownerLogin}, number=${projectNumberFromUrl})...`);
let projectId;
let resolvedProjectNumber = projectNumberFromUrl;
try {
@@ -8018,12 +7627,7 @@ jobs:
let contentNumber = null;
if (hasContentNumber || hasIssue || hasPullRequest) {
const rawContentNumber = hasContentNumber ? output.content_number : hasIssue ? output.issue : output.pull_request;
- const sanitizedContentNumber =
- rawContentNumber === undefined || rawContentNumber === null
- ? ""
- : typeof rawContentNumber === "number"
- ? rawContentNumber.toString()
- : String(rawContentNumber).trim();
+ const sanitizedContentNumber = rawContentNumber === undefined || rawContentNumber === null ? "" : typeof rawContentNumber === "number" ? rawContentNumber.toString() : String(rawContentNumber).trim();
if (!sanitizedContentNumber) {
core.warning("Content number field provided but empty; skipping project item update.");
} else if (!/^\d+$/.test(sanitizedContentNumber)) {
@@ -8033,14 +7637,7 @@ jobs:
}
}
if (contentNumber !== null) {
- const contentType =
- output.content_type === "pull_request"
- ? "PullRequest"
- : output.content_type === "issue"
- ? "Issue"
- : output.issue
- ? "Issue"
- : "PullRequest";
+ const contentType = output.content_type === "pull_request" ? "PullRequest" : output.content_type === "issue" ? "Issue" : output.issue ? "Issue" : "PullRequest";
const contentQuery =
contentType === "Issue"
? `query($owner: String!, $repo: String!, $number: Int!) {
@@ -8169,8 +7766,7 @@ jobs:
.join(" ");
let field = projectFields.find(f => f.name.toLowerCase() === normalizedFieldName.toLowerCase());
if (!field) {
- const isTextField =
- fieldName.toLowerCase() === "classification" || (typeof fieldValue === "string" && fieldValue.includes("|"));
+ const isTextField = fieldName.toLowerCase() === "classification" || (typeof fieldValue === "string" && fieldValue.includes("|"));
if (isTextField) {
try {
const createFieldResult = await github.graphql(
@@ -8246,10 +7842,7 @@ jobs:
let option = field.options.find(o => o.name === fieldValue);
if (!option) {
try {
- const allOptions = [
- ...field.options.map(o => ({ name: o.name, description: "", color: o.color || "GRAY" })),
- { name: String(fieldValue), description: "", color: "GRAY" },
- ];
+ const allOptions = [...field.options.map(o => ({ name: o.name, description: "", color: o.color || "GRAY" })), { name: String(fieldValue), description: "", color: "GRAY" }];
const createOptionResult = await github.graphql(
`mutation($fieldId: ID!, $fieldName: String!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {
updateProjectV2Field(input: {
diff --git a/pkg/workflow/js/add_comment.test.cjs b/pkg/workflow/js/add_comment.test.cjs
index ef7867c95d..31e2437f7d 100644
--- a/pkg/workflow/js/add_comment.test.cjs
+++ b/pkg/workflow/js/add_comment.test.cjs
@@ -1,1262 +1,617 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import fs from "fs";
import path from "path";
-
// Mock the global objects that GitHub Actions provides
const mockCore = {
- // Core logging functions
- debug: vi.fn(),
- info: vi.fn(),
- notice: vi.fn(),
- warning: vi.fn(),
- error: vi.fn(),
-
- // Core workflow functions
- setFailed: vi.fn(),
- setOutput: vi.fn(),
- exportVariable: vi.fn(),
- setSecret: vi.fn(),
-
- // Input/state functions (less commonly used but included for completeness)
- getInput: vi.fn(),
- getBooleanInput: vi.fn(),
- getMultilineInput: vi.fn(),
- getState: vi.fn(),
- saveState: vi.fn(),
-
- // Group functions
- startGroup: vi.fn(),
- endGroup: vi.fn(),
- group: vi.fn(),
-
- // Other utility functions
- addPath: vi.fn(),
- setCommandEcho: vi.fn(),
- isDebug: vi.fn().mockReturnValue(false),
- getIDToken: vi.fn(),
- toPlatformPath: vi.fn(),
- toPosixPath: vi.fn(),
- toWin32Path: vi.fn(),
-
- // Summary object with chainable methods
- summary: {
- addRaw: vi.fn().mockReturnThis(),
- write: vi.fn().mockResolvedValue(),
+ // Core logging functions
+ debug: vi.fn(),
+ info: vi.fn(),
+ notice: vi.fn(),
+ warning: vi.fn(),
+ error: vi.fn(),
+ // Core workflow functions
+ setFailed: vi.fn(),
+ setOutput: vi.fn(),
+ exportVariable: vi.fn(),
+ setSecret: vi.fn(),
+ // Input/state functions (less commonly used but included for completeness)
+ getInput: vi.fn(),
+ getBooleanInput: vi.fn(),
+ getMultilineInput: vi.fn(),
+ getState: vi.fn(),
+ saveState: vi.fn(),
+ // Group functions
+ startGroup: vi.fn(),
+ endGroup: vi.fn(),
+ group: vi.fn(),
+ // Other utility functions
+ addPath: vi.fn(),
+ setCommandEcho: vi.fn(),
+ isDebug: vi.fn().mockReturnValue(!1),
+ getIDToken: vi.fn(),
+ toPlatformPath: vi.fn(),
+ toPosixPath: vi.fn(),
+ toWin32Path: vi.fn(),
+ // Summary object with chainable methods
+ summary: { addRaw: vi.fn().mockReturnThis(), write: vi.fn().mockResolvedValue() },
},
-};
-
-const mockGithub = {
- rest: {
- issues: {
- createComment: vi.fn(),
- },
- },
-};
-
-const mockContext = {
- eventName: "issues",
- runId: 12345,
- repo: {
- owner: "testowner",
- repo: "testrepo",
- },
- payload: {
- issue: {
- number: 123,
- },
- repository: {
- html_url: "https://github.com/testowner/testrepo",
- },
- },
-};
-
+ mockGithub = { rest: { issues: { createComment: vi.fn() } } },
+ mockContext = { eventName: "issues", runId: 12345, repo: { owner: "testowner", repo: "testrepo" }, payload: { issue: { number: 123 }, repository: { html_url: "https://github.com/testowner/testrepo" } } };
// Set up global variables
-global.core = mockCore;
-global.github = mockGithub;
-global.context = mockContext;
-
-describe("add_comment.cjs", () => {
- let createCommentScript;
-
- let tempFilePath;
-
- // Helper function to set agent output via file
- const setAgentOutput = data => {
- tempFilePath = path.join("/tmp", `test_agent_output_${Date.now()}_${Math.random().toString(36).slice(2)}.json`);
- const content = typeof data === "string" ? data : JSON.stringify(data);
- fs.writeFileSync(tempFilePath, content);
- process.env.GH_AW_AGENT_OUTPUT = tempFilePath;
- };
-
- beforeEach(() => {
- // Reset all mocks
- vi.clearAllMocks();
-
- // Reset environment variables
- delete process.env.GH_AW_AGENT_OUTPUT;
- delete process.env.GITHUB_WORKFLOW;
-
- // Reset context to default state
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 123 };
-
- // Read the script content
- const scriptPath = path.join(process.cwd(), "add_comment.cjs");
- createCommentScript = fs.readFileSync(scriptPath, "utf8");
- });
-
- afterEach(() => {
- // Clean up temporary file
- if (tempFilePath && require("fs").existsSync(tempFilePath)) {
- require("fs").unlinkSync(tempFilePath);
- tempFilePath = undefined;
- }
- });
-
- it("should skip when no agent output is provided", async () => {
- // Remove the output content environment variable
- delete process.env.GH_AW_AGENT_OUTPUT;
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("No GH_AW_AGENT_OUTPUT environment variable found");
- expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled();
- });
-
- it("should skip when agent output is empty", async () => {
- setAgentOutput("");
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("Agent output content is empty");
- expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled();
- });
-
- it("should skip when not in issue or PR context", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test comment content",
- },
- ],
- });
- global.context.eventName = "push"; // Not an issue or PR event
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation');
- expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled();
- });
-
- it("should create comment on issue successfully", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test comment content",
- },
- ],
- });
- global.context.eventName = "issues";
-
- const mockComment = {
- id: 456,
- html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456",
- };
-
- mockGithub.rest.issues.createComment.mockResolvedValue({
- data: mockComment,
- });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- body: expect.stringContaining("Test comment content"),
- });
-
- expect(mockCore.setOutput).toHaveBeenCalledWith("comment_id", 456);
- expect(mockCore.setOutput).toHaveBeenCalledWith("comment_url", mockComment.html_url);
- expect(mockCore.summary.addRaw).toHaveBeenCalled();
- expect(mockCore.summary.write).toHaveBeenCalled();
- });
-
- it("should create comment on pull request successfully", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test PR comment content",
- },
- ],
- });
- global.context.eventName = "pull_request";
- global.context.payload.pull_request = { number: 789 };
- delete global.context.payload.issue; // Remove issue from payload
-
- const mockComment = {
- id: 789,
- html_url: "https://github.com/testowner/testrepo/issues/789#issuecomment-789",
- };
-
- mockGithub.rest.issues.createComment.mockResolvedValue({
- data: mockComment,
- });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 789,
- body: expect.stringContaining("Test PR comment content"),
- });
- });
-
- it("should include run information in comment body", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test content",
- },
- ],
- });
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 123 }; // Make sure issue context is properly set
-
- const mockComment = {
- id: 456,
- html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456",
- };
-
- mockGithub.rest.issues.createComment.mockResolvedValue({
- data: mockComment,
- });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalled();
- expect(mockGithub.rest.issues.createComment.mock.calls).toHaveLength(1);
-
- const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
- expect(callArgs.body).toContain("Test content");
- // Now uses messages.cjs with pirate-themed default footer
- expect(callArgs.body).toContain("This treasure was crafted by");
- expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345");
- });
-
- it("should include workflow source in footer when GH_AW_WORKFLOW_SOURCE is provided", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test content with source",
- },
- ],
- });
- process.env.GH_AW_WORKFLOW_NAME = "Test Workflow";
- process.env.GH_AW_WORKFLOW_SOURCE = "githubnext/agentics/workflows/ci-doctor.md@v1.0.0";
- process.env.GH_AW_WORKFLOW_SOURCE_URL = "https://github.com/githubnext/agentics/tree/v1.0.0/workflows/ci-doctor.md";
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 123 };
-
- const mockComment = {
- id: 456,
- html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456",
- };
-
- mockGithub.rest.issues.createComment.mockResolvedValue({
- data: mockComment,
- });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalled();
- const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
-
- // Check that the footer contains the expected elements (now using messages.cjs with pirate-themed footer)
- expect(callArgs.body).toContain("Test content with source");
- expect(callArgs.body).toContain("[🏴☠️ Test Workflow]");
- expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345");
- expect(callArgs.body).toContain("gh aw add githubnext/agentics/workflows/ci-doctor.md@v1.0.0");
- });
-
- it("should not include workflow source footer when GH_AW_WORKFLOW_SOURCE is not provided", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test content without source",
- },
- ],
- });
- process.env.GH_AW_WORKFLOW_NAME = "Test Workflow";
- delete process.env.GH_AW_WORKFLOW_SOURCE; // Ensure it's not set
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 123 };
-
- const mockComment = {
- id: 456,
- html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456",
- };
-
- mockGithub.rest.issues.createComment.mockResolvedValue({
- data: mockComment,
- });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalled();
- const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
-
- // Check that the footer does NOT contain the workflow source (now using messages.cjs with pirate-themed footer)
- expect(callArgs.body).toContain("Test content without source");
- expect(callArgs.body).toContain("[🏴☠️ Test Workflow]");
- expect(callArgs.body).not.toContain("gh aw add");
- });
-
- it("should use GITHUB_SERVER_URL when repository context is not available", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test content with custom server",
- },
- ],
- });
- process.env.GITHUB_SERVER_URL = "https://github.enterprise.com";
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 123 };
- // Remove repository context to force use of GITHUB_SERVER_URL
- delete global.context.payload.repository;
-
- const mockComment = {
- id: 456,
- html_url: "https://github.enterprise.com/testowner/testrepo/issues/123#issuecomment-456",
- };
-
- mockGithub.rest.issues.createComment.mockResolvedValue({
- data: mockComment,
- });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalled();
- const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
-
- // Check that the footer uses the custom GitHub server URL
- expect(callArgs.body).toContain("Test content with custom server");
- expect(callArgs.body).toContain("https://github.enterprise.com/testowner/testrepo/actions/runs/12345");
- expect(callArgs.body).not.toContain("https://github.com/testowner/testrepo/actions/runs/12345");
-
- // Clean up
- delete process.env.GITHUB_SERVER_URL;
- });
-
- it("should fallback to https://github.com when GITHUB_SERVER_URL is not set and repository context is missing", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test content with fallback",
- },
- ],
- });
- delete process.env.GITHUB_SERVER_URL;
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 123 };
- // Remove repository context to test fallback
- delete global.context.payload.repository;
-
- const mockComment = {
- id: 456,
- html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456",
- };
-
- mockGithub.rest.issues.createComment.mockResolvedValue({
- data: mockComment,
- });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalled();
- const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
-
- // Check that the footer uses the default https://github.com
- expect(callArgs.body).toContain("Test content with fallback");
- expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345");
- });
-
- it("should include triggering issue number in footer when in issue context", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Comment from issue context",
- },
- ],
- });
- process.env.GH_AW_WORKFLOW_NAME = "Test Workflow";
-
- // Simulate issue context
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 42 };
-
- const mockComment = {
- id: 789,
- html_url: "https://github.com/testowner/testrepo/issues/42#issuecomment-789",
- };
-
- mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
-
- // Check that the footer includes reference to triggering issue (now using messages.cjs with pirate-themed footer)
- expect(callArgs.body).toContain("Comment from issue context");
- expect(callArgs.body).toContain("[🏴☠️ Test Workflow]");
- expect(callArgs.body).toContain("#42");
- });
-
- it("should include triggering PR number in footer when in PR context", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Comment from PR context",
- },
- ],
- });
- process.env.GH_AW_WORKFLOW_NAME = "Test Workflow";
-
- // Simulate PR context
- global.context.eventName = "pull_request";
- delete global.context.payload.issue;
- global.context.payload.pull_request = { number: 123 };
-
- const mockComment = {
- id: 890,
- html_url: "https://github.com/testowner/testrepo/pull/123#issuecomment-890",
- };
-
- mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
-
- // Check that the footer includes reference to triggering PR (now using messages.cjs with pirate-themed footer)
- expect(callArgs.body).toContain("Comment from PR context");
- expect(callArgs.body).toContain("[🏴☠️ Test Workflow]");
- expect(callArgs.body).toContain("#123");
-
- // Clean up
- delete global.context.payload.pull_request;
- });
-
- it("should use header level 4 for related items in comments", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test comment with related items",
- },
- ],
- });
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 123 };
-
- // Set environment variables for created items
- process.env.GH_AW_CREATED_ISSUE_URL = "https://github.com/testowner/testrepo/issues/456";
- process.env.GH_AW_CREATED_ISSUE_NUMBER = "456";
- process.env.GH_AW_CREATED_DISCUSSION_URL = "https://github.com/testowner/testrepo/discussions/789";
- process.env.GH_AW_CREATED_DISCUSSION_NUMBER = "789";
- process.env.GH_AW_CREATED_PULL_REQUEST_URL = "https://github.com/testowner/testrepo/pull/101";
- process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER = "101";
-
- const mockComment = {
- id: 890,
- html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-890",
- };
-
- mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
-
- // Check that the related items section uses header level 4 (####)
- expect(callArgs.body).toContain("#### Related Items");
- // Check that it uses exactly 4 hashes, not 2
- expect(callArgs.body).toMatch(/####\s+Related Items/);
- expect(callArgs.body).not.toMatch(/^##\s+Related Items/m);
- expect(callArgs.body).not.toMatch(/\*\*Related Items:\*\*/);
-
- // Check that the references are included
- expect(callArgs.body).toContain("- Issue: [#456](https://github.com/testowner/testrepo/issues/456)");
- expect(callArgs.body).toContain("- Discussion: [#789](https://github.com/testowner/testrepo/discussions/789)");
- expect(callArgs.body).toContain("- Pull Request: [#101](https://github.com/testowner/testrepo/pull/101)");
-
- // Clean up
- delete process.env.GH_AW_CREATED_ISSUE_URL;
- delete process.env.GH_AW_CREATED_ISSUE_NUMBER;
- delete process.env.GH_AW_CREATED_DISCUSSION_URL;
- delete process.env.GH_AW_CREATED_DISCUSSION_NUMBER;
- delete process.env.GH_AW_CREATED_PULL_REQUEST_URL;
- delete process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER;
- });
-
- it("should use header level 4 for related items in staged mode preview", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test comment in staged mode",
- },
- ],
- });
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 123 };
-
- // Enable staged mode
- process.env.GH_AW_SAFE_OUTPUTS_STAGED = "true";
-
- // Set environment variables for created items
- process.env.GH_AW_CREATED_ISSUE_URL = "https://github.com/testowner/testrepo/issues/456";
- process.env.GH_AW_CREATED_ISSUE_NUMBER = "456";
- process.env.GH_AW_CREATED_DISCUSSION_URL = "https://github.com/testowner/testrepo/discussions/789";
- process.env.GH_AW_CREATED_DISCUSSION_NUMBER = "789";
- process.env.GH_AW_CREATED_PULL_REQUEST_URL = "https://github.com/testowner/testrepo/pull/101";
- process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER = "101";
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- // Check that summary was written with correct header level 4
- expect(mockCore.summary.addRaw).toHaveBeenCalled();
- const summaryContent = mockCore.summary.addRaw.mock.calls[0][0];
-
- // Check that the related items section uses header level 4 (####)
- expect(summaryContent).toContain("#### Related Items");
- // Check that it uses exactly 4 hashes, not 2
- expect(summaryContent).toMatch(/####\s+Related Items/);
- expect(summaryContent).not.toMatch(/^##\s+Related Items/m);
- expect(summaryContent).not.toMatch(/\*\*Related Items:\*\*/);
-
- // Check that the references are included
- expect(summaryContent).toContain("- Issue: [#456](https://github.com/testowner/testrepo/issues/456)");
- expect(summaryContent).toContain("- Discussion: [#789](https://github.com/testowner/testrepo/discussions/789)");
- expect(summaryContent).toContain("- Pull Request: [#101](https://github.com/testowner/testrepo/pull/101)");
-
- // Clean up
- delete process.env.GH_AW_SAFE_OUTPUTS_STAGED;
- delete process.env.GH_AW_CREATED_ISSUE_URL;
- delete process.env.GH_AW_CREATED_ISSUE_NUMBER;
- delete process.env.GH_AW_CREATED_DISCUSSION_URL;
- delete process.env.GH_AW_CREATED_DISCUSSION_NUMBER;
- delete process.env.GH_AW_CREATED_PULL_REQUEST_URL;
- delete process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER;
- });
-
- it("should create comment on discussion using GraphQL when in discussion_comment context", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test discussion comment",
- },
- ],
- });
-
- // Simulate discussion_comment context
- global.context.eventName = "discussion_comment";
- global.context.payload.discussion = { number: 1993 };
- global.context.payload.comment = {
- id: 12345,
- node_id: "DC_kwDOABcD1M4AaBbC", // Node ID of the comment to reply to
- };
- delete global.context.payload.issue;
- delete global.context.payload.pull_request;
-
- // Mock GraphQL responses for discussion
- const mockGraphqlResponse = vi.fn();
- mockGraphqlResponse
- .mockResolvedValueOnce({
- // First call: get discussion ID
- repository: {
- discussion: {
- id: "D_kwDOPc1QR84BpqRs",
- url: "https://github.com/testowner/testrepo/discussions/1993",
- },
- },
- })
- .mockResolvedValueOnce({
- // Second call: create comment with replyToId
- addDiscussionComment: {
- comment: {
- id: "DC_kwDOPc1QR84BpqRt",
- body: "Test discussion comment",
- createdAt: "2025-10-19T22:00:00Z",
- url: "https://github.com/testowner/testrepo/discussions/1993#discussioncomment-123",
- },
- },
- });
-
- global.github.graphql = mockGraphqlResponse;
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- // Verify GraphQL was called with correct queries
- expect(mockGraphqlResponse).toHaveBeenCalledTimes(2);
-
- // First call should fetch discussion ID
- expect(mockGraphqlResponse.mock.calls[0][0]).toContain("query");
- expect(mockGraphqlResponse.mock.calls[0][0]).toContain("discussion(number: $num)");
- expect(mockGraphqlResponse.mock.calls[0][1]).toEqual({
- owner: "testowner",
- repo: "testrepo",
- num: 1993,
- });
-
- // Second call should create the comment with replyToId
- expect(mockGraphqlResponse.mock.calls[1][0]).toContain("mutation");
- expect(mockGraphqlResponse.mock.calls[1][0]).toContain("addDiscussionComment");
- expect(mockGraphqlResponse.mock.calls[1][0]).toContain("replyToId");
- expect(mockGraphqlResponse.mock.calls[1][1].body).toContain("Test discussion comment");
- expect(mockGraphqlResponse.mock.calls[1][1].replyToId).toBe("DC_kwDOABcD1M4AaBbC");
-
- // Verify REST API was NOT called
- expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled();
-
- // Verify outputs were set
- expect(mockCore.setOutput).toHaveBeenCalledWith("comment_id", "DC_kwDOPc1QR84BpqRt");
- expect(mockCore.setOutput).toHaveBeenCalledWith("comment_url", "https://github.com/testowner/testrepo/discussions/1993#discussioncomment-123");
-
- // Clean up
- delete global.github.graphql;
- delete global.context.payload.discussion;
- delete global.context.payload.comment;
- });
-
- it("should create comment on discussion using GraphQL when GITHUB_AW_COMMENT_DISCUSSION is true (explicit discussion mode)", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test explicit discussion comment",
- item_number: 2001,
- },
- ],
- });
-
- // Set target configuration to use explicit number
- process.env.GH_AW_COMMENT_TARGET = "*";
- // Force discussion mode via environment variable
- process.env.GITHUB_AW_COMMENT_DISCUSSION = "true";
-
- // Use a non-discussion context (e.g., issues) to test explicit override
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 123 };
- delete global.context.payload.discussion;
- delete global.context.payload.pull_request;
-
- // Mock GraphQL responses for discussion
- const mockGraphqlResponse = vi.fn();
- mockGraphqlResponse
- .mockResolvedValueOnce({
- // First call: get discussion ID
- repository: {
- discussion: {
- id: "D_kwDOPc1QR84BpqRu",
- url: "https://github.com/testowner/testrepo/discussions/2001",
- },
- },
- })
- .mockResolvedValueOnce({
- // Second call: create comment (no replyToId for non-comment context)
- addDiscussionComment: {
- comment: {
- id: "DC_kwDOPc1QR84BpqRv",
- body: "Test explicit discussion comment",
- createdAt: "2025-10-22T12:00:00Z",
- url: "https://github.com/testowner/testrepo/discussions/2001#discussioncomment-456",
- },
- },
- });
-
- global.github.graphql = mockGraphqlResponse;
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- // Verify GraphQL was called with correct queries
- expect(mockGraphqlResponse).toHaveBeenCalledTimes(2);
-
- // First call should fetch discussion ID for the explicit number
- expect(mockGraphqlResponse.mock.calls[0][0]).toContain("query");
- expect(mockGraphqlResponse.mock.calls[0][0]).toContain("discussion(number: $num)");
- expect(mockGraphqlResponse.mock.calls[0][1]).toEqual({
- owner: "testowner",
- repo: "testrepo",
- num: 2001, // Should use the item_number from the comment item
- });
-
- // Second call should create the comment (without replyToId since this is not discussion_comment context)
- expect(mockGraphqlResponse.mock.calls[1][0]).toContain("mutation");
- expect(mockGraphqlResponse.mock.calls[1][0]).toContain("addDiscussionComment");
- expect(mockGraphqlResponse.mock.calls[1][1].body).toContain("Test explicit discussion comment");
- // Should NOT have replyToId since we're not in discussion_comment context
- expect(mockGraphqlResponse.mock.calls[1][1].replyToId).toBeUndefined();
-
- // Verify REST API was NOT called (should use GraphQL for discussions)
- expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled();
-
- // Verify outputs were set
- expect(mockCore.setOutput).toHaveBeenCalledWith("comment_id", "DC_kwDOPc1QR84BpqRv");
- expect(mockCore.setOutput).toHaveBeenCalledWith("comment_url", "https://github.com/testowner/testrepo/discussions/2001#discussioncomment-456");
-
- // Verify info logging shows it's targeting a discussion
- expect(mockCore.info).toHaveBeenCalledWith("Creating comment on discussion #2001");
-
- // Clean up
- delete process.env.GH_AW_COMMENT_TARGET;
- delete process.env.GITHUB_AW_COMMENT_DISCUSSION;
- delete global.github.graphql;
- });
-
- it("should replace temporary ID references in comment body using the temporary ID map", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "This comment references issue #aw_aabbccdd1122 which was created earlier.",
- },
- ],
- });
-
- // Set up the temporary ID map from the create_issue job
- process.env.GH_AW_TEMPORARY_ID_MAP = JSON.stringify({ aw_aabbccdd1122: 456 });
-
- mockGithub.rest.issues.createComment.mockResolvedValue({
- data: {
- id: 99999,
- html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-99999",
- },
- });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- // The comment body should have the temporary ID replaced
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith(
- expect.objectContaining({
- body: expect.stringContaining("#456"),
- })
- );
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith(
- expect.objectContaining({
- body: expect.not.stringContaining("#aw_aabbccdd1122"),
- })
- );
-
- // Clean up
- delete process.env.GH_AW_TEMPORARY_ID_MAP;
- });
-
- it("should load temporary ID map and log the count", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test comment",
- },
- ],
- });
-
- // Set up the temporary ID map
- process.env.GH_AW_TEMPORARY_ID_MAP = JSON.stringify({ aw_abc123: 100, aw_def456: 200 });
-
- mockGithub.rest.issues.createComment.mockResolvedValue({
- data: {
- id: 99999,
- html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-99999",
- },
- });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- // Should log that the map was loaded with 2 entries
- expect(mockCore.info).toHaveBeenCalledWith("Loaded temporary ID map with 2 entries");
-
- // Clean up
- delete process.env.GH_AW_TEMPORARY_ID_MAP;
- });
-
- it("should handle empty temporary ID map gracefully", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Comment with #aw_000000000000 that won't be resolved",
- },
- ],
- });
-
- // Empty or missing temporary ID map
- process.env.GH_AW_TEMPORARY_ID_MAP = "{}";
-
- mockGithub.rest.issues.createComment.mockResolvedValue({
- data: {
- id: 99999,
- html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-99999",
- },
- });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- // The unresolved reference should remain in the body
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith(
- expect.objectContaining({
- body: expect.stringContaining("#aw_000000000000"),
- })
- );
-
- // Clean up
- delete process.env.GH_AW_TEMPORARY_ID_MAP;
- });
-
- it("should use custom footer message when GH_AW_SAFE_OUTPUT_MESSAGES is configured", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test comment with custom footer",
- },
- ],
- });
- process.env.GH_AW_WORKFLOW_NAME = "Custom Workflow";
- process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({
- footer: "> Custom AI footer by [{workflow_name}]({run_url})",
- footerInstall: "> Custom install: `gh aw add {workflow_source}`",
- });
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 456 };
-
- const mockComment = {
- id: 999,
- html_url: "https://github.com/testowner/testrepo/issues/456#issuecomment-999",
- };
-
- mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
-
- // Check that the custom footer is used
- expect(callArgs.body).toContain("Test comment with custom footer");
- expect(callArgs.body).toContain("Custom AI footer by [Custom Workflow]");
- expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345");
- // Should NOT contain the pirate-themed default footer
- expect(callArgs.body).not.toContain("Ahoy!");
- expect(callArgs.body).not.toContain("treasure was crafted");
-
- // Clean up
- delete process.env.GH_AW_SAFE_OUTPUT_MESSAGES;
- });
-
- it("should use custom footer with install instructions when workflow source is provided", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Test comment with custom footer and install",
- },
- ],
- });
- process.env.GH_AW_WORKFLOW_NAME = "Custom Workflow";
- process.env.GH_AW_WORKFLOW_SOURCE = "owner/repo/workflow.md@main";
- process.env.GH_AW_WORKFLOW_SOURCE_URL = "https://github.com/owner/repo";
- process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({
- footer: "> Generated by [{workflow_name}]({run_url})",
- footerInstall: "> Install: `gh aw add {workflow_source}`",
- });
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 789 };
-
- const mockComment = {
- id: 1001,
- html_url: "https://github.com/testowner/testrepo/issues/789#issuecomment-1001",
- };
-
- mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
-
- // Check that the custom footer and install instructions are used
- expect(callArgs.body).toContain("Test comment with custom footer and install");
- expect(callArgs.body).toContain("Generated by [Custom Workflow]");
- expect(callArgs.body).toContain("Install: `gh aw add owner/repo/workflow.md@main`");
- // Should NOT contain the pirate-themed default messages
- expect(callArgs.body).not.toContain("Ahoy!");
- expect(callArgs.body).not.toContain("plunder this workflow");
-
- // Clean up
- delete process.env.GH_AW_SAFE_OUTPUT_MESSAGES;
- delete process.env.GH_AW_WORKFLOW_SOURCE;
- delete process.env.GH_AW_WORKFLOW_SOURCE_URL;
- });
-
- it("should hide older comments when hide-older-comments is enabled", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "New comment from workflow",
- },
- ],
- });
- process.env.GITHUB_WORKFLOW = "test-workflow-123";
- process.env.GH_AW_HIDE_OLDER_COMMENTS = "true";
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 100 };
-
- // Mock existing comments with the same workflow-id
- mockGithub.rest.issues.listComments = vi.fn().mockResolvedValue({
- data: [
- {
- id: 1,
- node_id: "IC_oldcomment1",
- body: "Old comment 1\n\n",
- },
- {
- id: 2,
- node_id: "IC_oldcomment2",
- body: "Old comment 2\n\n",
- },
- {
- id: 3,
- node_id: "IC_othercomment",
- body: "Comment from different workflow",
- },
- ],
- });
-
- // Mock the minimizeComment GraphQL mutation
- mockGithub.graphql = vi.fn().mockResolvedValue({
- minimizeComment: {
- minimizedComment: {
- isMinimized: true,
- },
- },
- });
-
- const mockNewComment = {
- id: 4,
- html_url: "https://github.com/testowner/testrepo/issues/100#issuecomment-4",
- };
- mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- // Verify that listComments was called
- expect(mockGithub.rest.issues.listComments).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 100,
- per_page: 100,
- page: 1,
- });
-
- // Verify that minimizeComment was called twice (for the two matching comments)
- expect(mockGithub.graphql).toHaveBeenCalledTimes(2);
- expect(mockGithub.graphql).toHaveBeenCalledWith(
- expect.stringContaining("minimizeComment"),
- expect.objectContaining({
- nodeId: "IC_oldcomment1",
- classifier: "OUTDATED",
- })
- );
- expect(mockGithub.graphql).toHaveBeenCalledWith(
- expect.stringContaining("minimizeComment"),
- expect.objectContaining({
- nodeId: "IC_oldcomment2",
- classifier: "OUTDATED",
- })
- );
-
- // Verify that the new comment was created
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith(
- expect.objectContaining({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 100,
- body: expect.stringContaining("New comment from workflow"),
- })
- );
-
- // Clean up
- delete process.env.GITHUB_WORKFLOW;
- delete process.env.GH_AW_HIDE_OLDER_COMMENTS;
- });
-
- it("should not hide comments when hide-older-comments is not enabled", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "New comment without hiding",
- },
- ],
- });
- process.env.GITHUB_WORKFLOW = "test-workflow-456";
- // Note: GH_AW_HIDE_OLDER_COMMENTS is not set
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 200 };
-
- mockGithub.rest.issues.listComments = vi.fn();
- mockGithub.graphql = vi.fn();
-
- const mockNewComment = {
- id: 5,
- html_url: "https://github.com/testowner/testrepo/issues/200#issuecomment-5",
- };
- mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- // Verify that listComments was NOT called (hide feature not enabled)
- expect(mockGithub.rest.issues.listComments).not.toHaveBeenCalled();
- expect(mockGithub.graphql).not.toHaveBeenCalled();
-
- // Verify that the new comment was created
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalled();
-
- // Clean up
- delete process.env.GITHUB_WORKFLOW;
- });
-
- it("should skip hiding when workflow-id is not available", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "Comment without workflow-id",
- },
- ],
- });
- // Note: GITHUB_WORKFLOW is not set
- process.env.GH_AW_HIDE_OLDER_COMMENTS = "true";
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 300 };
-
- mockGithub.rest.issues.listComments = vi.fn();
- mockGithub.graphql = vi.fn();
-
- const mockNewComment = {
- id: 6,
- html_url: "https://github.com/testowner/testrepo/issues/300#issuecomment-6",
- };
- mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- // Verify that hiding was skipped (no workflow-id available)
- expect(mockGithub.rest.issues.listComments).not.toHaveBeenCalled();
- expect(mockGithub.graphql).not.toHaveBeenCalled();
-
- // Verify that the new comment was created
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalled();
-
- // Clean up
- delete process.env.GH_AW_HIDE_OLDER_COMMENTS;
- });
-
- it("should respect allowed-reasons when hiding comments", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "New comment with allowed reasons",
- },
- ],
- });
- process.env.GITHUB_WORKFLOW = "test-workflow-789";
- process.env.GH_AW_HIDE_OLDER_COMMENTS = "true";
- process.env.GH_AW_ALLOWED_REASONS = JSON.stringify(["OUTDATED", "RESOLVED"]);
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 400 };
-
- // Mock existing comments
- mockGithub.rest.issues.listComments = vi.fn().mockResolvedValue({
- data: [
- {
- id: 1,
- node_id: "IC_oldcomment1",
- body: "Old comment\n\n",
- },
- ],
- });
-
- // Mock the minimizeComment GraphQL mutation
- mockGithub.graphql = vi.fn().mockResolvedValue({
- minimizeComment: {
- minimizedComment: {
- isMinimized: true,
- },
- },
- });
-
- const mockNewComment = {
- id: 2,
- html_url: "https://github.com/testowner/testrepo/issues/400#issuecomment-2",
- };
- mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- // Verify that minimizeComment was called with OUTDATED (the default)
- expect(mockGithub.graphql).toHaveBeenCalledWith(
- expect.stringContaining("minimizeComment"),
- expect.objectContaining({
- nodeId: "IC_oldcomment1",
- classifier: "OUTDATED",
- })
- );
-
- // Clean up
- delete process.env.GITHUB_WORKFLOW;
- delete process.env.GH_AW_HIDE_OLDER_COMMENTS;
- delete process.env.GH_AW_ALLOWED_REASONS;
- });
-
- it("should skip hiding when reason is not in allowed-reasons", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "New comment with restricted reasons",
- },
- ],
- });
- process.env.GITHUB_WORKFLOW = "test-workflow-999";
- process.env.GH_AW_HIDE_OLDER_COMMENTS = "true";
- // Only allow SPAM, but default reason is OUTDATED
- process.env.GH_AW_ALLOWED_REASONS = JSON.stringify(["SPAM"]);
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 500 };
-
- mockGithub.rest.issues.listComments = vi.fn();
- mockGithub.graphql = vi.fn();
-
- const mockNewComment = {
- id: 3,
- html_url: "https://github.com/testowner/testrepo/issues/500#issuecomment-3",
- };
- mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- // Verify that hiding was skipped (OUTDATED not in allowed reasons)
- expect(mockGithub.rest.issues.listComments).not.toHaveBeenCalled();
- expect(mockGithub.graphql).not.toHaveBeenCalled();
-
- // Verify that the new comment was still created
- expect(mockGithub.rest.issues.createComment).toHaveBeenCalled();
-
- // Clean up
- delete process.env.GITHUB_WORKFLOW;
- delete process.env.GH_AW_HIDE_OLDER_COMMENTS;
- delete process.env.GH_AW_ALLOWED_REASONS;
- });
-
- it("should support lowercase allowed-reasons", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_comment",
- body: "New comment with lowercase reasons",
- },
- ],
- });
- process.env.GITHUB_WORKFLOW = "test-workflow-lowercase";
- process.env.GH_AW_HIDE_OLDER_COMMENTS = "true";
- // Use lowercase reasons
- process.env.GH_AW_ALLOWED_REASONS = JSON.stringify(["outdated", "resolved"]);
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 600 };
-
- // Mock existing comments
- mockGithub.rest.issues.listComments = vi.fn().mockResolvedValue({
- data: [
- {
- id: 1,
- node_id: "IC_oldcomment1",
- body: "Old comment\n\n",
- },
- ],
- });
-
- // Mock the minimizeComment GraphQL mutation
- mockGithub.graphql = vi.fn().mockResolvedValue({
- minimizeComment: {
- minimizedComment: {
- isMinimized: true,
- },
- },
- });
-
- const mockNewComment = {
- id: 4,
- html_url: "https://github.com/testowner/testrepo/issues/600#issuecomment-4",
+((global.core = mockCore),
+ (global.github = mockGithub),
+ (global.context = mockContext),
+ describe("add_comment.cjs", () => {
+ let createCommentScript, tempFilePath;
+ // Helper function to set agent output via file
+ const setAgentOutput = data => {
+ tempFilePath = path.join("/tmp", `test_agent_output_${Date.now()}_${Math.random().toString(36).slice(2)}.json`);
+ const content = "string" == typeof data ? data : JSON.stringify(data);
+ (fs.writeFileSync(tempFilePath, content), (process.env.GH_AW_AGENT_OUTPUT = tempFilePath));
};
- mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment });
-
- // Execute the script
- await eval(`(async () => { ${createCommentScript} })()`);
-
- // Verify that minimizeComment was called with OUTDATED (uppercase, normalized)
- expect(mockGithub.graphql).toHaveBeenCalledWith(
- expect.stringContaining("minimizeComment"),
- expect.objectContaining({
- nodeId: "IC_oldcomment1",
- classifier: "OUTDATED",
- })
- );
-
- // Clean up
- delete process.env.GITHUB_WORKFLOW;
- delete process.env.GH_AW_HIDE_OLDER_COMMENTS;
- delete process.env.GH_AW_ALLOWED_REASONS;
- });
-});
+ (beforeEach(() => {
+ // Reset all mocks
+ (vi.clearAllMocks(),
+ // Reset environment variables
+ delete process.env.GH_AW_AGENT_OUTPUT,
+ delete process.env.GITHUB_WORKFLOW,
+ // Reset context to default state
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 123 }));
+ // Read the script content
+ const scriptPath = path.join(process.cwd(), "add_comment.cjs");
+ createCommentScript = fs.readFileSync(scriptPath, "utf8");
+ }),
+ afterEach(() => {
+ // Clean up temporary file
+ tempFilePath && require("fs").existsSync(tempFilePath) && (require("fs").unlinkSync(tempFilePath), (tempFilePath = void 0));
+ }),
+ it("should skip when no agent output is provided", async () => {
+ // Remove the output content environment variable
+ (delete process.env.GH_AW_AGENT_OUTPUT,
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockCore.info).toHaveBeenCalledWith("No GH_AW_AGENT_OUTPUT environment variable found"),
+ expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled());
+ }),
+ it("should skip when agent output is empty", async () => {
+ (setAgentOutput(""),
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockCore.info).toHaveBeenCalledWith("Agent output content is empty"),
+ expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled());
+ }),
+ it("should skip when not in issue or PR context", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment content" }] }),
+ (global.context.eventName = "push"), // Not an issue or PR event
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockCore.info).toHaveBeenCalledWith('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'),
+ expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled());
+ }),
+ it("should create comment on issue successfully", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment content" }] }), (global.context.eventName = "issues"));
+ const mockComment = { id: 456, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }),
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({ owner: "testowner", repo: "testrepo", issue_number: 123, body: expect.stringContaining("Test comment content") }),
+ expect(mockCore.setOutput).toHaveBeenCalledWith("comment_id", 456),
+ expect(mockCore.setOutput).toHaveBeenCalledWith("comment_url", mockComment.html_url),
+ expect(mockCore.summary.addRaw).toHaveBeenCalled(),
+ expect(mockCore.summary.write).toHaveBeenCalled());
+ }),
+ it("should create comment on pull request successfully", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test PR comment content" }] }), (global.context.eventName = "pull_request"), (global.context.payload.pull_request = { number: 789 }), delete global.context.payload.issue); // Remove issue from payload
+ const mockComment = { id: 789, html_url: "https://github.com/testowner/testrepo/issues/789#issuecomment-789" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }),
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({ owner: "testowner", repo: "testrepo", issue_number: 789, body: expect.stringContaining("Test PR comment content") }));
+ }),
+ it("should include run information in comment body", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test content" }] }), (global.context.eventName = "issues"), (global.context.payload.issue = { number: 123 })); // Make sure issue context is properly set
+ const mockComment = { id: 456, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }),
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(),
+ expect(mockGithub.rest.issues.createComment.mock.calls).toHaveLength(1));
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ (expect(callArgs.body).toContain("Test content"),
+ // Now uses messages.cjs with pirate-themed default footer
+ expect(callArgs.body).toContain("This treasure was crafted by"),
+ expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345"));
+ }),
+ it("should include workflow source in footer when GH_AW_WORKFLOW_SOURCE is provided", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test content with source" }] }),
+ (process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"),
+ (process.env.GH_AW_WORKFLOW_SOURCE = "githubnext/agentics/workflows/ci-doctor.md@v1.0.0"),
+ (process.env.GH_AW_WORKFLOW_SOURCE_URL = "https://github.com/githubnext/agentics/tree/v1.0.0/workflows/ci-doctor.md"),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 123 }));
+ const mockComment = { id: 456, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }),
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalled());
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ // Check that the footer contains the expected elements (now using messages.cjs with pirate-themed footer)
+ (expect(callArgs.body).toContain("Test content with source"),
+ expect(callArgs.body).toContain("[🏴☠️ Test Workflow]"),
+ expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345"),
+ expect(callArgs.body).toContain("gh aw add githubnext/agentics/workflows/ci-doctor.md@v1.0.0"));
+ }),
+ it("should not include workflow source footer when GH_AW_WORKFLOW_SOURCE is not provided", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test content without source" }] }),
+ (process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"),
+ delete process.env.GH_AW_WORKFLOW_SOURCE, // Ensure it's not set
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 123 }));
+ const mockComment = { id: 456, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }),
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalled());
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ // Check that the footer does NOT contain the workflow source (now using messages.cjs with pirate-themed footer)
+ (expect(callArgs.body).toContain("Test content without source"), expect(callArgs.body).toContain("[🏴☠️ Test Workflow]"), expect(callArgs.body).not.toContain("gh aw add"));
+ }),
+ it("should use GITHUB_SERVER_URL when repository context is not available", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test content with custom server" }] }),
+ (process.env.GITHUB_SERVER_URL = "https://github.enterprise.com"),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 123 }),
+ // Remove repository context to force use of GITHUB_SERVER_URL
+ delete global.context.payload.repository);
+ const mockComment = { id: 456, html_url: "https://github.enterprise.com/testowner/testrepo/issues/123#issuecomment-456" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }),
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalled());
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ // Check that the footer uses the custom GitHub server URL
+ (expect(callArgs.body).toContain("Test content with custom server"),
+ expect(callArgs.body).toContain("https://github.enterprise.com/testowner/testrepo/actions/runs/12345"),
+ expect(callArgs.body).not.toContain("https://github.com/testowner/testrepo/actions/runs/12345"),
+ // Clean up
+ delete process.env.GITHUB_SERVER_URL);
+ }),
+ it("should fallback to https://github.com when GITHUB_SERVER_URL is not set and repository context is missing", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test content with fallback" }] }),
+ delete process.env.GITHUB_SERVER_URL,
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 123 }),
+ // Remove repository context to test fallback
+ delete global.context.payload.repository);
+ const mockComment = { id: 456, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }),
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalled());
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ // Check that the footer uses the default https://github.com
+ (expect(callArgs.body).toContain("Test content with fallback"), expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345"));
+ }),
+ it("should include triggering issue number in footer when in issue context", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Comment from issue context" }] }),
+ (process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"),
+ // Simulate issue context
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 42 }));
+ const mockComment = { id: 789, html_url: "https://github.com/testowner/testrepo/issues/42#issuecomment-789" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }),
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`));
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ // Check that the footer includes reference to triggering issue (now using messages.cjs with pirate-themed footer)
+ (expect(callArgs.body).toContain("Comment from issue context"), expect(callArgs.body).toContain("[🏴☠️ Test Workflow]"), expect(callArgs.body).toContain("#42"));
+ }),
+ it("should include triggering PR number in footer when in PR context", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Comment from PR context" }] }),
+ (process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"),
+ // Simulate PR context
+ (global.context.eventName = "pull_request"),
+ delete global.context.payload.issue,
+ (global.context.payload.pull_request = { number: 123 }));
+ const mockComment = { id: 890, html_url: "https://github.com/testowner/testrepo/pull/123#issuecomment-890" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }),
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`));
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ // Check that the footer includes reference to triggering PR (now using messages.cjs with pirate-themed footer)
+ (expect(callArgs.body).toContain("Comment from PR context"),
+ expect(callArgs.body).toContain("[🏴☠️ Test Workflow]"),
+ expect(callArgs.body).toContain("#123"),
+ // Clean up
+ delete global.context.payload.pull_request);
+ }),
+ it("should use header level 4 for related items in comments", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment with related items" }] }),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 123 }),
+ // Set environment variables for created items
+ (process.env.GH_AW_CREATED_ISSUE_URL = "https://github.com/testowner/testrepo/issues/456"),
+ (process.env.GH_AW_CREATED_ISSUE_NUMBER = "456"),
+ (process.env.GH_AW_CREATED_DISCUSSION_URL = "https://github.com/testowner/testrepo/discussions/789"),
+ (process.env.GH_AW_CREATED_DISCUSSION_NUMBER = "789"),
+ (process.env.GH_AW_CREATED_PULL_REQUEST_URL = "https://github.com/testowner/testrepo/pull/101"),
+ (process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER = "101"));
+ const mockComment = { id: 890, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-890" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }),
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`));
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ // Check that the related items section uses header level 4 (####)
+ (expect(callArgs.body).toContain("#### Related Items"),
+ // Check that it uses exactly 4 hashes, not 2
+ expect(callArgs.body).toMatch(/####\s+Related Items/),
+ expect(callArgs.body).not.toMatch(/^##\s+Related Items/m),
+ expect(callArgs.body).not.toMatch(/\*\*Related Items:\*\*/),
+ // Check that the references are included
+ expect(callArgs.body).toContain("- Issue: [#456](https://github.com/testowner/testrepo/issues/456)"),
+ expect(callArgs.body).toContain("- Discussion: [#789](https://github.com/testowner/testrepo/discussions/789)"),
+ expect(callArgs.body).toContain("- Pull Request: [#101](https://github.com/testowner/testrepo/pull/101)"),
+ // Clean up
+ delete process.env.GH_AW_CREATED_ISSUE_URL,
+ delete process.env.GH_AW_CREATED_ISSUE_NUMBER,
+ delete process.env.GH_AW_CREATED_DISCUSSION_URL,
+ delete process.env.GH_AW_CREATED_DISCUSSION_NUMBER,
+ delete process.env.GH_AW_CREATED_PULL_REQUEST_URL,
+ delete process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER);
+ }),
+ it("should use header level 4 for related items in staged mode preview", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment in staged mode" }] }),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 123 }),
+ // Enable staged mode
+ (process.env.GH_AW_SAFE_OUTPUTS_STAGED = "true"),
+ // Set environment variables for created items
+ (process.env.GH_AW_CREATED_ISSUE_URL = "https://github.com/testowner/testrepo/issues/456"),
+ (process.env.GH_AW_CREATED_ISSUE_NUMBER = "456"),
+ (process.env.GH_AW_CREATED_DISCUSSION_URL = "https://github.com/testowner/testrepo/discussions/789"),
+ (process.env.GH_AW_CREATED_DISCUSSION_NUMBER = "789"),
+ (process.env.GH_AW_CREATED_PULL_REQUEST_URL = "https://github.com/testowner/testrepo/pull/101"),
+ (process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER = "101"),
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`),
+ // Check that summary was written with correct header level 4
+ expect(mockCore.summary.addRaw).toHaveBeenCalled());
+ const summaryContent = mockCore.summary.addRaw.mock.calls[0][0];
+ // Check that the related items section uses header level 4 (####)
+ (expect(summaryContent).toContain("#### Related Items"),
+ // Check that it uses exactly 4 hashes, not 2
+ expect(summaryContent).toMatch(/####\s+Related Items/),
+ expect(summaryContent).not.toMatch(/^##\s+Related Items/m),
+ expect(summaryContent).not.toMatch(/\*\*Related Items:\*\*/),
+ // Check that the references are included
+ expect(summaryContent).toContain("- Issue: [#456](https://github.com/testowner/testrepo/issues/456)"),
+ expect(summaryContent).toContain("- Discussion: [#789](https://github.com/testowner/testrepo/discussions/789)"),
+ expect(summaryContent).toContain("- Pull Request: [#101](https://github.com/testowner/testrepo/pull/101)"),
+ // Clean up
+ delete process.env.GH_AW_SAFE_OUTPUTS_STAGED,
+ delete process.env.GH_AW_CREATED_ISSUE_URL,
+ delete process.env.GH_AW_CREATED_ISSUE_NUMBER,
+ delete process.env.GH_AW_CREATED_DISCUSSION_URL,
+ delete process.env.GH_AW_CREATED_DISCUSSION_NUMBER,
+ delete process.env.GH_AW_CREATED_PULL_REQUEST_URL,
+ delete process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER);
+ }),
+ it("should create comment on discussion using GraphQL when in discussion_comment context", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test discussion comment" }] }),
+ // Simulate discussion_comment context
+ (global.context.eventName = "discussion_comment"),
+ (global.context.payload.discussion = { number: 1993 }),
+ (global.context.payload.comment = { id: 12345, node_id: "DC_kwDOABcD1M4AaBbC" }),
+ delete global.context.payload.issue,
+ delete global.context.payload.pull_request);
+ // Mock GraphQL responses for discussion
+ const mockGraphqlResponse = vi.fn();
+ (mockGraphqlResponse
+ .mockResolvedValueOnce({
+ // First call: get discussion ID
+ repository: { discussion: { id: "D_kwDOPc1QR84BpqRs", url: "https://github.com/testowner/testrepo/discussions/1993" } },
+ })
+ .mockResolvedValueOnce({
+ // Second call: create comment with replyToId
+ addDiscussionComment: { comment: { id: "DC_kwDOPc1QR84BpqRt", body: "Test discussion comment", createdAt: "2025-10-19T22:00:00Z", url: "https://github.com/testowner/testrepo/discussions/1993#discussioncomment-123" } },
+ }),
+ (global.github.graphql = mockGraphqlResponse),
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`),
+ // Verify GraphQL was called with correct queries
+ expect(mockGraphqlResponse).toHaveBeenCalledTimes(2),
+ // First call should fetch discussion ID
+ expect(mockGraphqlResponse.mock.calls[0][0]).toContain("query"),
+ expect(mockGraphqlResponse.mock.calls[0][0]).toContain("discussion(number: $num)"),
+ expect(mockGraphqlResponse.mock.calls[0][1]).toEqual({ owner: "testowner", repo: "testrepo", num: 1993 }),
+ // Second call should create the comment with replyToId
+ expect(mockGraphqlResponse.mock.calls[1][0]).toContain("mutation"),
+ expect(mockGraphqlResponse.mock.calls[1][0]).toContain("addDiscussionComment"),
+ expect(mockGraphqlResponse.mock.calls[1][0]).toContain("replyToId"),
+ expect(mockGraphqlResponse.mock.calls[1][1].body).toContain("Test discussion comment"),
+ expect(mockGraphqlResponse.mock.calls[1][1].replyToId).toBe("DC_kwDOABcD1M4AaBbC"),
+ // Verify REST API was NOT called
+ expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(),
+ // Verify outputs were set
+ expect(mockCore.setOutput).toHaveBeenCalledWith("comment_id", "DC_kwDOPc1QR84BpqRt"),
+ expect(mockCore.setOutput).toHaveBeenCalledWith("comment_url", "https://github.com/testowner/testrepo/discussions/1993#discussioncomment-123"),
+ // Clean up
+ delete global.github.graphql,
+ delete global.context.payload.discussion,
+ delete global.context.payload.comment);
+ }),
+ it("should create comment on discussion using GraphQL when GITHUB_AW_COMMENT_DISCUSSION is true (explicit discussion mode)", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test explicit discussion comment", item_number: 2001 }] }),
+ // Set target configuration to use explicit number
+ (process.env.GH_AW_COMMENT_TARGET = "*"),
+ // Force discussion mode via environment variable
+ (process.env.GITHUB_AW_COMMENT_DISCUSSION = "true"),
+ // Use a non-discussion context (e.g., issues) to test explicit override
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 123 }),
+ delete global.context.payload.discussion,
+ delete global.context.payload.pull_request);
+ // Mock GraphQL responses for discussion
+ const mockGraphqlResponse = vi.fn();
+ (mockGraphqlResponse
+ .mockResolvedValueOnce({
+ // First call: get discussion ID
+ repository: { discussion: { id: "D_kwDOPc1QR84BpqRu", url: "https://github.com/testowner/testrepo/discussions/2001" } },
+ })
+ .mockResolvedValueOnce({
+ // Second call: create comment (no replyToId for non-comment context)
+ addDiscussionComment: { comment: { id: "DC_kwDOPc1QR84BpqRv", body: "Test explicit discussion comment", createdAt: "2025-10-22T12:00:00Z", url: "https://github.com/testowner/testrepo/discussions/2001#discussioncomment-456" } },
+ }),
+ (global.github.graphql = mockGraphqlResponse),
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`),
+ // Verify GraphQL was called with correct queries
+ expect(mockGraphqlResponse).toHaveBeenCalledTimes(2),
+ // First call should fetch discussion ID for the explicit number
+ expect(mockGraphqlResponse.mock.calls[0][0]).toContain("query"),
+ expect(mockGraphqlResponse.mock.calls[0][0]).toContain("discussion(number: $num)"),
+ expect(mockGraphqlResponse.mock.calls[0][1]).toEqual({ owner: "testowner", repo: "testrepo", num: 2001 }),
+ // Second call should create the comment (without replyToId since this is not discussion_comment context)
+ expect(mockGraphqlResponse.mock.calls[1][0]).toContain("mutation"),
+ expect(mockGraphqlResponse.mock.calls[1][0]).toContain("addDiscussionComment"),
+ expect(mockGraphqlResponse.mock.calls[1][1].body).toContain("Test explicit discussion comment"),
+ // Should NOT have replyToId since we're not in discussion_comment context
+ expect(mockGraphqlResponse.mock.calls[1][1].replyToId).toBeUndefined(),
+ // Verify REST API was NOT called (should use GraphQL for discussions)
+ expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(),
+ // Verify outputs were set
+ expect(mockCore.setOutput).toHaveBeenCalledWith("comment_id", "DC_kwDOPc1QR84BpqRv"),
+ expect(mockCore.setOutput).toHaveBeenCalledWith("comment_url", "https://github.com/testowner/testrepo/discussions/2001#discussioncomment-456"),
+ // Verify info logging shows it's targeting a discussion
+ expect(mockCore.info).toHaveBeenCalledWith("Creating comment on discussion #2001"),
+ // Clean up
+ delete process.env.GH_AW_COMMENT_TARGET,
+ delete process.env.GITHUB_AW_COMMENT_DISCUSSION,
+ delete global.github.graphql);
+ }),
+ it("should replace temporary ID references in comment body using the temporary ID map", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "This comment references issue #aw_aabbccdd1122 which was created earlier." }] }),
+ // Set up the temporary ID map from the create_issue job
+ (process.env.GH_AW_TEMPORARY_ID_MAP = JSON.stringify({ aw_aabbccdd1122: 456 })),
+ mockGithub.rest.issues.createComment.mockResolvedValue({ data: { id: 99999, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-99999" } }),
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`),
+ // The comment body should have the temporary ID replaced
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith(expect.objectContaining({ body: expect.stringContaining("#456") })),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith(expect.objectContaining({ body: expect.not.stringContaining("#aw_aabbccdd1122") })),
+ // Clean up
+ delete process.env.GH_AW_TEMPORARY_ID_MAP);
+ }),
+ it("should load temporary ID map and log the count", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment" }] }),
+ // Set up the temporary ID map
+ (process.env.GH_AW_TEMPORARY_ID_MAP = JSON.stringify({ aw_abc123: 100, aw_def456: 200 })),
+ mockGithub.rest.issues.createComment.mockResolvedValue({ data: { id: 99999, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-99999" } }),
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`),
+ // Should log that the map was loaded with 2 entries
+ expect(mockCore.info).toHaveBeenCalledWith("Loaded temporary ID map with 2 entries"),
+ // Clean up
+ delete process.env.GH_AW_TEMPORARY_ID_MAP);
+ }),
+ it("should handle empty temporary ID map gracefully", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Comment with #aw_000000000000 that won't be resolved" }] }),
+ // Empty or missing temporary ID map
+ (process.env.GH_AW_TEMPORARY_ID_MAP = "{}"),
+ mockGithub.rest.issues.createComment.mockResolvedValue({ data: { id: 99999, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-99999" } }),
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`),
+ // The unresolved reference should remain in the body
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith(expect.objectContaining({ body: expect.stringContaining("#aw_000000000000") })),
+ // Clean up
+ delete process.env.GH_AW_TEMPORARY_ID_MAP);
+ }),
+ it("should use custom footer message when GH_AW_SAFE_OUTPUT_MESSAGES is configured", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment with custom footer" }] }),
+ (process.env.GH_AW_WORKFLOW_NAME = "Custom Workflow"),
+ (process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ footer: "> Custom AI footer by [{workflow_name}]({run_url})", footerInstall: "> Custom install: `gh aw add {workflow_source}`" })),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 456 }));
+ const mockComment = { id: 999, html_url: "https://github.com/testowner/testrepo/issues/456#issuecomment-999" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }),
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`));
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ // Check that the custom footer is used
+ (expect(callArgs.body).toContain("Test comment with custom footer"),
+ expect(callArgs.body).toContain("Custom AI footer by [Custom Workflow]"),
+ expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345"),
+ // Should NOT contain the pirate-themed default footer
+ expect(callArgs.body).not.toContain("Ahoy!"),
+ expect(callArgs.body).not.toContain("treasure was crafted"),
+ // Clean up
+ delete process.env.GH_AW_SAFE_OUTPUT_MESSAGES);
+ }),
+ it("should use custom footer with install instructions when workflow source is provided", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment with custom footer and install" }] }),
+ (process.env.GH_AW_WORKFLOW_NAME = "Custom Workflow"),
+ (process.env.GH_AW_WORKFLOW_SOURCE = "owner/repo/workflow.md@main"),
+ (process.env.GH_AW_WORKFLOW_SOURCE_URL = "https://github.com/owner/repo"),
+ (process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ footer: "> Generated by [{workflow_name}]({run_url})", footerInstall: "> Install: `gh aw add {workflow_source}`" })),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 789 }));
+ const mockComment = { id: 1001, html_url: "https://github.com/testowner/testrepo/issues/789#issuecomment-1001" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }),
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`));
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ // Check that the custom footer and install instructions are used
+ (expect(callArgs.body).toContain("Test comment with custom footer and install"),
+ expect(callArgs.body).toContain("Generated by [Custom Workflow]"),
+ expect(callArgs.body).toContain("Install: `gh aw add owner/repo/workflow.md@main`"),
+ // Should NOT contain the pirate-themed default messages
+ expect(callArgs.body).not.toContain("Ahoy!"),
+ expect(callArgs.body).not.toContain("plunder this workflow"),
+ // Clean up
+ delete process.env.GH_AW_SAFE_OUTPUT_MESSAGES,
+ delete process.env.GH_AW_WORKFLOW_SOURCE,
+ delete process.env.GH_AW_WORKFLOW_SOURCE_URL);
+ }),
+ it("should hide older comments when hide-older-comments is enabled", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "New comment from workflow" }] }),
+ (process.env.GITHUB_WORKFLOW = "test-workflow-123"),
+ (process.env.GH_AW_HIDE_OLDER_COMMENTS = "true"),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 100 }),
+ // Mock existing comments with the same workflow-id
+ (mockGithub.rest.issues.listComments = vi.fn().mockResolvedValue({
+ data: [
+ { id: 1, node_id: "IC_oldcomment1", body: "Old comment 1\n\n\x3c!-- workflow-id: test-workflow-123 --\x3e" },
+ { id: 2, node_id: "IC_oldcomment2", body: "Old comment 2\n\n\x3c!-- workflow-id: test-workflow-123 --\x3e" },
+ { id: 3, node_id: "IC_othercomment", body: "Comment from different workflow" },
+ ],
+ })),
+ // Mock the minimizeComment GraphQL mutation
+ (mockGithub.graphql = vi.fn().mockResolvedValue({ minimizeComment: { minimizedComment: { isMinimized: !0 } } })));
+ const mockNewComment = { id: 4, html_url: "https://github.com/testowner/testrepo/issues/100#issuecomment-4" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }),
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`),
+ // Verify that listComments was called
+ expect(mockGithub.rest.issues.listComments).toHaveBeenCalledWith({ owner: "testowner", repo: "testrepo", issue_number: 100, per_page: 100, page: 1 }),
+ // Verify that minimizeComment was called twice (for the two matching comments)
+ expect(mockGithub.graphql).toHaveBeenCalledTimes(2),
+ expect(mockGithub.graphql).toHaveBeenCalledWith(expect.stringContaining("minimizeComment"), expect.objectContaining({ nodeId: "IC_oldcomment1", classifier: "OUTDATED" })),
+ expect(mockGithub.graphql).toHaveBeenCalledWith(expect.stringContaining("minimizeComment"), expect.objectContaining({ nodeId: "IC_oldcomment2", classifier: "OUTDATED" })),
+ // Verify that the new comment was created
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith(expect.objectContaining({ owner: "testowner", repo: "testrepo", issue_number: 100, body: expect.stringContaining("New comment from workflow") })),
+ // Clean up
+ delete process.env.GITHUB_WORKFLOW,
+ delete process.env.GH_AW_HIDE_OLDER_COMMENTS);
+ }),
+ it("should not hide comments when hide-older-comments is not enabled", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "New comment without hiding" }] }),
+ (process.env.GITHUB_WORKFLOW = "test-workflow-456"),
+ // Note: GH_AW_HIDE_OLDER_COMMENTS is not set
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 200 }),
+ (mockGithub.rest.issues.listComments = vi.fn()),
+ (mockGithub.graphql = vi.fn()));
+ const mockNewComment = { id: 5, html_url: "https://github.com/testowner/testrepo/issues/200#issuecomment-5" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }),
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`),
+ // Verify that listComments was NOT called (hide feature not enabled)
+ expect(mockGithub.rest.issues.listComments).not.toHaveBeenCalled(),
+ expect(mockGithub.graphql).not.toHaveBeenCalled(),
+ // Verify that the new comment was created
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(),
+ // Clean up
+ delete process.env.GITHUB_WORKFLOW);
+ }),
+ it("should skip hiding when workflow-id is not available", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Comment without workflow-id" }] }),
+ // Note: GITHUB_WORKFLOW is not set
+ (process.env.GH_AW_HIDE_OLDER_COMMENTS = "true"),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 300 }),
+ (mockGithub.rest.issues.listComments = vi.fn()),
+ (mockGithub.graphql = vi.fn()));
+ const mockNewComment = { id: 6, html_url: "https://github.com/testowner/testrepo/issues/300#issuecomment-6" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }),
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`),
+ // Verify that hiding was skipped (no workflow-id available)
+ expect(mockGithub.rest.issues.listComments).not.toHaveBeenCalled(),
+ expect(mockGithub.graphql).not.toHaveBeenCalled(),
+ // Verify that the new comment was created
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(),
+ // Clean up
+ delete process.env.GH_AW_HIDE_OLDER_COMMENTS);
+ }),
+ it("should respect allowed-reasons when hiding comments", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "New comment with allowed reasons" }] }),
+ (process.env.GITHUB_WORKFLOW = "test-workflow-789"),
+ (process.env.GH_AW_HIDE_OLDER_COMMENTS = "true"),
+ (process.env.GH_AW_ALLOWED_REASONS = JSON.stringify(["OUTDATED", "RESOLVED"])),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 400 }),
+ // Mock existing comments
+ (mockGithub.rest.issues.listComments = vi.fn().mockResolvedValue({ data: [{ id: 1, node_id: "IC_oldcomment1", body: "Old comment\n\n\x3c!-- workflow-id: test-workflow-789 --\x3e" }] })),
+ // Mock the minimizeComment GraphQL mutation
+ (mockGithub.graphql = vi.fn().mockResolvedValue({ minimizeComment: { minimizedComment: { isMinimized: !0 } } })));
+ const mockNewComment = { id: 2, html_url: "https://github.com/testowner/testrepo/issues/400#issuecomment-2" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }),
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`),
+ // Verify that minimizeComment was called with OUTDATED (the default)
+ expect(mockGithub.graphql).toHaveBeenCalledWith(expect.stringContaining("minimizeComment"), expect.objectContaining({ nodeId: "IC_oldcomment1", classifier: "OUTDATED" })),
+ // Clean up
+ delete process.env.GITHUB_WORKFLOW,
+ delete process.env.GH_AW_HIDE_OLDER_COMMENTS,
+ delete process.env.GH_AW_ALLOWED_REASONS);
+ }),
+ it("should skip hiding when reason is not in allowed-reasons", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "New comment with restricted reasons" }] }),
+ (process.env.GITHUB_WORKFLOW = "test-workflow-999"),
+ (process.env.GH_AW_HIDE_OLDER_COMMENTS = "true"),
+ // Only allow SPAM, but default reason is OUTDATED
+ (process.env.GH_AW_ALLOWED_REASONS = JSON.stringify(["SPAM"])),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 500 }),
+ (mockGithub.rest.issues.listComments = vi.fn()),
+ (mockGithub.graphql = vi.fn()));
+ const mockNewComment = { id: 3, html_url: "https://github.com/testowner/testrepo/issues/500#issuecomment-3" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }),
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`),
+ // Verify that hiding was skipped (OUTDATED not in allowed reasons)
+ expect(mockGithub.rest.issues.listComments).not.toHaveBeenCalled(),
+ expect(mockGithub.graphql).not.toHaveBeenCalled(),
+ // Verify that the new comment was still created
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(),
+ // Clean up
+ delete process.env.GITHUB_WORKFLOW,
+ delete process.env.GH_AW_HIDE_OLDER_COMMENTS,
+ delete process.env.GH_AW_ALLOWED_REASONS);
+ }),
+ it("should support lowercase allowed-reasons", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "New comment with lowercase reasons" }] }),
+ (process.env.GITHUB_WORKFLOW = "test-workflow-lowercase"),
+ (process.env.GH_AW_HIDE_OLDER_COMMENTS = "true"),
+ // Use lowercase reasons
+ (process.env.GH_AW_ALLOWED_REASONS = JSON.stringify(["outdated", "resolved"])),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 600 }),
+ // Mock existing comments
+ (mockGithub.rest.issues.listComments = vi.fn().mockResolvedValue({ data: [{ id: 1, node_id: "IC_oldcomment1", body: "Old comment\n\n\x3c!-- workflow-id: test-workflow-lowercase --\x3e" }] })),
+ // Mock the minimizeComment GraphQL mutation
+ (mockGithub.graphql = vi.fn().mockResolvedValue({ minimizeComment: { minimizedComment: { isMinimized: !0 } } })));
+ const mockNewComment = { id: 4, html_url: "https://github.com/testowner/testrepo/issues/600#issuecomment-4" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }),
+ // Execute the script
+ await eval(`(async () => { ${createCommentScript} })()`),
+ // Verify that minimizeComment was called with OUTDATED (uppercase, normalized)
+ expect(mockGithub.graphql).toHaveBeenCalledWith(expect.stringContaining("minimizeComment"), expect.objectContaining({ nodeId: "IC_oldcomment1", classifier: "OUTDATED" })),
+ // Clean up
+ delete process.env.GITHUB_WORKFLOW,
+ delete process.env.GH_AW_HIDE_OLDER_COMMENTS,
+ delete process.env.GH_AW_ALLOWED_REASONS);
+ }));
+ }));
diff --git a/pkg/workflow/js/add_labels.test.cjs b/pkg/workflow/js/add_labels.test.cjs
index 066f535208..992f9f1af9 100644
--- a/pkg/workflow/js/add_labels.test.cjs
+++ b/pkg/workflow/js/add_labels.test.cjs
@@ -1,1088 +1,498 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import fs from "fs";
import path from "path";
-
// Mock the global objects that GitHub Actions provides
const mockCore = {
- // Core logging functions
- debug: vi.fn(),
- info: vi.fn(),
- notice: vi.fn(),
- warning: vi.fn(),
- error: vi.fn(),
-
- // Core workflow functions
- setFailed: vi.fn(),
- setOutput: vi.fn(),
- exportVariable: vi.fn(),
- setSecret: vi.fn(),
-
- // Input/state functions (less commonly used but included for completeness)
- getInput: vi.fn(),
- getBooleanInput: vi.fn(),
- getMultilineInput: vi.fn(),
- getState: vi.fn(),
- saveState: vi.fn(),
-
- // Group functions
- startGroup: vi.fn(),
- endGroup: vi.fn(),
- group: vi.fn(),
-
- // Other utility functions
- addPath: vi.fn(),
- setCommandEcho: vi.fn(),
- isDebug: vi.fn().mockReturnValue(false),
- getIDToken: vi.fn(),
- toPlatformPath: vi.fn(),
- toPosixPath: vi.fn(),
- toWin32Path: vi.fn(),
-
- // Summary object with chainable methods
- summary: {
- addRaw: vi.fn().mockReturnThis(),
- write: vi.fn().mockResolvedValue(),
+ // Core logging functions
+ debug: vi.fn(),
+ info: vi.fn(),
+ notice: vi.fn(),
+ warning: vi.fn(),
+ error: vi.fn(),
+ // Core workflow functions
+ setFailed: vi.fn(),
+ setOutput: vi.fn(),
+ exportVariable: vi.fn(),
+ setSecret: vi.fn(),
+ // Input/state functions (less commonly used but included for completeness)
+ getInput: vi.fn(),
+ getBooleanInput: vi.fn(),
+ getMultilineInput: vi.fn(),
+ getState: vi.fn(),
+ saveState: vi.fn(),
+ // Group functions
+ startGroup: vi.fn(),
+ endGroup: vi.fn(),
+ group: vi.fn(),
+ // Other utility functions
+ addPath: vi.fn(),
+ setCommandEcho: vi.fn(),
+ isDebug: vi.fn().mockReturnValue(!1),
+ getIDToken: vi.fn(),
+ toPlatformPath: vi.fn(),
+ toPosixPath: vi.fn(),
+ toWin32Path: vi.fn(),
+ // Summary object with chainable methods
+ summary: { addRaw: vi.fn().mockReturnThis(), write: vi.fn().mockResolvedValue() },
},
-};
-
-const mockGithub = {
- rest: {
- issues: {
- addLabels: vi.fn(),
- },
- },
-};
-
-const mockContext = {
- eventName: "issues",
- repo: {
- owner: "testowner",
- repo: "testrepo",
- },
- payload: {
- issue: {
- number: 123,
- },
- },
-};
-
+ mockGithub = { rest: { issues: { addLabels: vi.fn() } } },
+ mockContext = { eventName: "issues", repo: { owner: "testowner", repo: "testrepo" }, payload: { issue: { number: 123 } } };
// Set up global variables
-global.core = mockCore;
-global.github = mockGithub;
-global.context = mockContext;
-
-describe("add_labels.cjs", () => {
- let addLabelsScript;
-
- let tempFilePath;
-
- // Helper function to set agent output via file
- const setAgentOutput = data => {
- tempFilePath = path.join("/tmp", `test_agent_output_${Date.now()}_${Math.random().toString(36).slice(2)}.json`);
- const content = typeof data === "string" ? data : JSON.stringify(data);
- fs.writeFileSync(tempFilePath, content);
- process.env.GH_AW_AGENT_OUTPUT = tempFilePath;
- };
-
- beforeEach(() => {
- // Reset all mocks
- vi.clearAllMocks();
-
- // Reset environment variables
- delete process.env.GH_AW_AGENT_OUTPUT;
- delete process.env.GH_AW_LABELS_ALLOWED;
- delete process.env.GH_AW_LABELS_MAX_COUNT;
-
- // Reset context to default state
- global.context.eventName = "issues";
- global.context.payload.issue = { number: 123 };
- delete global.context.payload.pull_request;
-
- // Read the script content
- const scriptPath = path.join(process.cwd(), "add_labels.cjs");
- addLabelsScript = fs.readFileSync(scriptPath, "utf8");
- });
-
- afterEach(() => {
- // Clean up temporary file
- if (tempFilePath && require("fs").existsSync(tempFilePath)) {
- require("fs").unlinkSync(tempFilePath);
- tempFilePath = undefined;
- }
- });
-
- describe("Environment variable validation", () => {
- it("should skip when no agent output is provided", async () => {
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- delete process.env.GH_AW_AGENT_OUTPUT;
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("No GH_AW_AGENT_OUTPUT environment variable found");
- expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled();
- });
-
- it("should skip when agent output is empty", async () => {
- setAgentOutput("");
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("Agent output content is empty");
- expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled();
- });
-
- it("should work when allowed labels are not provided (any labels allowed)", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement", "custom-label"],
- },
- ],
- });
- delete process.env.GH_AW_LABELS_ALLOWED;
- process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test label processing
-
- mockGithub.rest.issues.addLabels.mockResolvedValue({});
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("No label addition restrictions - any label additions are allowed");
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- labels: ["bug", "enhancement", "custom-label"],
- });
- });
-
- it("should work when allowed labels list is empty (any labels allowed)", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement", "custom-label"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = " ";
- process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test label processing
-
- mockGithub.rest.issues.addLabels.mockResolvedValue({});
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("No label addition restrictions - any label additions are allowed");
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- labels: ["bug", "enhancement", "custom-label"],
- });
- });
-
- it("should enforce allowed labels when restrictions are set", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement", "custom-label", "documentation"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test label filtering
-
- mockGithub.rest.issues.addLabels.mockResolvedValue({});
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith(`Allowed label additions: ${JSON.stringify(["bug", "enhancement"])}`);
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- labels: ["bug", "enhancement"], // 'custom-label' and 'documentation' filtered out
- });
- });
-
- it("should fail when max count is invalid", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- process.env.GH_AW_LABELS_MAX_COUNT = "invalid";
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.setFailed).toHaveBeenCalledWith("Invalid max value: invalid. Must be a positive integer");
- expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled();
- });
-
- it("should fail when max count is zero", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- process.env.GH_AW_LABELS_MAX_COUNT = "0";
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.setFailed).toHaveBeenCalledWith("Invalid max value: 0. Must be a positive integer");
- expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled();
- });
-
- it("should use default max count when not specified", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement", "feature", "documentation"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement,feature,documentation";
- delete process.env.GH_AW_LABELS_MAX_COUNT;
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("Max count: 1");
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- labels: ["bug"], // Only first 1 due to default max count
- });
- });
- });
-
- describe("Context validation", () => {
- it("should skip when not in issue or PR context (with default target)", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- global.context.eventName = "push";
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith('Target is "triggering" but not running in issue or pull request context, skipping label addition');
- expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled();
- });
-
- it("should work with issue_comment event", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- global.context.eventName = "issue_comment";
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalled();
- });
-
- it("should work with pull_request event", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- global.context.eventName = "pull_request";
- global.context.payload.pull_request = { number: 456 };
- delete global.context.payload.issue;
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 456,
- labels: ["bug"],
- });
- });
-
- it("should work with pull_request_review event", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- global.context.eventName = "pull_request_review";
- global.context.payload.pull_request = { number: 789 };
- delete global.context.payload.issue;
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 789,
- labels: ["bug"],
- });
- });
-
- it("should fail when issue context detected but no issue in payload", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- global.context.eventName = "issues";
- delete global.context.payload.issue;
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.setFailed).toHaveBeenCalledWith("Issue context detected but no issue found in payload");
- expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled();
- });
-
- it("should fail when PR context detected but no PR in payload", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- global.context.eventName = "pull_request";
- delete global.context.payload.issue;
- delete global.context.payload.pull_request;
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.setFailed).toHaveBeenCalledWith("Pull request context detected but no pull request found in payload");
- expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled();
- });
- });
-
- describe("Label parsing and validation", () => {
- it("should parse labels from agent output and add valid ones", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement", "documentation"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement,feature";
- process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test label filtering
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- labels: ["bug", "enhancement"], // 'documentation' not in allowed list
- });
-
- expect(mockCore.setOutput).toHaveBeenCalledWith("labels_added", "bug\nenhancement");
- expect(mockCore.summary.addRaw).toHaveBeenCalled();
- expect(mockCore.summary.write).toHaveBeenCalled();
- });
-
- it("should skip empty lines in agent output", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test label processing
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- labels: ["bug", "enhancement"],
- });
- });
-
- it("should fail when line starts with dash (removal indication)", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "-enhancement"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.setFailed).toHaveBeenCalledWith("Label removal is not permitted. Found line starting with '-': -enhancement");
- expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled();
- });
-
- it("should remove duplicate labels", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement", "bug", "enhancement"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test deduplication
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- labels: ["bug", "enhancement"], // Duplicates removed
- });
- });
-
- it("should enforce max count limit", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement", "feature", "documentation", "question"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement,feature,documentation,question";
- process.env.GH_AW_LABELS_MAX_COUNT = "2";
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("Too many labels (5), limiting to 2");
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- labels: ["bug", "enhancement"], // Only first 2
- });
- });
-
- it("should skip when no valid labels found", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["invalid", "another-invalid"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("No labels to add");
- expect(mockCore.setOutput).toHaveBeenCalledWith("labels_added", "");
- expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("No labels were added"));
- expect(mockGithub.rest.issues.addLabels).not.toHaveBeenCalled();
- });
- });
-
- describe("GitHub API integration", () => {
- it("should successfully add labels to issue", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement,feature";
- process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test label addition
-
- mockGithub.rest.issues.addLabels.mockResolvedValue({});
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- labels: ["bug", "enhancement"],
- });
-
- expect(mockCore.info).toHaveBeenCalledWith("Successfully added 2 labels to issue #123");
- expect(mockCore.setOutput).toHaveBeenCalledWith("labels_added", "bug\nenhancement");
-
- const summaryCall = mockCore.summary.addRaw.mock.calls.find(call => call[0].includes("Successfully added 2 label(s) to issue #123"));
- expect(summaryCall).toBeDefined();
- expect(summaryCall[0]).toContain("- `bug`");
- expect(summaryCall[0]).toContain("- `enhancement`");
- });
-
- it("should successfully add labels to pull request", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- global.context.eventName = "pull_request";
- global.context.payload.pull_request = { number: 456 };
- delete global.context.payload.issue;
-
- mockGithub.rest.issues.addLabels.mockResolvedValue({});
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("Successfully added 1 labels to pull request #456");
-
- const summaryCall = mockCore.summary.addRaw.mock.calls.find(call => call[0].includes("Successfully added 1 label(s) to pull request #456"));
- expect(summaryCall).toBeDefined();
- });
-
- it("should handle GitHub API errors", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
-
- const apiError = new Error("Label does not exist");
- mockGithub.rest.issues.addLabels.mockRejectedValue(apiError);
-
- const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.error).toHaveBeenCalledWith("Failed to add labels: Label does not exist");
- expect(mockCore.setFailed).toHaveBeenCalledWith("Failed to add labels: Label does not exist");
- });
-
- it("should handle non-Error objects in catch block", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
-
- const stringError = "Something went wrong";
- mockGithub.rest.issues.addLabels.mockRejectedValue(stringError);
-
- const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.error).toHaveBeenCalledWith("Failed to add labels: Something went wrong");
- expect(mockCore.setFailed).toHaveBeenCalledWith("Failed to add labels: Something went wrong");
- });
- });
-
- describe("Output and logging", () => {
- it("should log agent output content length", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("Agent output content length: 64");
- });
-
- it("should log allowed labels and max count", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement,feature";
- process.env.GH_AW_LABELS_MAX_COUNT = "5";
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith(`Allowed label additions: ${JSON.stringify(["bug", "enhancement", "feature"])}`);
- expect(mockCore.info).toHaveBeenCalledWith("Max count: 5");
- });
-
- it("should log requested labels", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement", "invalid"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith(`Requested labels: ${JSON.stringify(["bug", "enhancement", "invalid"])}`);
- });
-
- it("should log final labels being added", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
- process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test logging
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith(`Adding 2 labels to issue #123: ${JSON.stringify(["bug", "enhancement"])}`);
- });
- });
-
- describe("Edge cases", () => {
- it("should handle whitespace in allowed labels", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = " bug , enhancement , feature ";
- process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test label processing
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith(`Allowed label additions: ${JSON.stringify(["bug", "enhancement", "feature"])}`);
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- labels: ["bug", "enhancement"],
- });
- });
-
- it("should handle empty entries in allowed labels", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,,enhancement,";
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith(`Allowed label additions: ${JSON.stringify(["bug", "enhancement"])}`);
- });
-
- it("should handle single label output", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug"],
- },
- ],
- });
- process.env.GH_AW_LABELS_ALLOWED = "bug,enhancement";
-
- mockGithub.rest.issues.addLabels.mockResolvedValue({});
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- labels: ["bug"],
- });
-
- expect(mockCore.setOutput).toHaveBeenCalledWith("labels_added", "bug");
- });
-
- it("should handle duplicate labels by removing duplicates", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug", "enhancement", "bug", "automation", "enhancement"],
- },
- ],
- });
- process.env.GH_AW_LABELS_MAX_COUNT = "10"; // Set high max to test deduplication
-
- mockGithub.rest.issues.addLabels.mockResolvedValue({});
-
- // Execute the script
- await eval(`(async () => { ${addLabelsScript} })()`);
-
- expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({
- owner: "testowner",
- repo: "testrepo",
- issue_number: 123,
- labels: ["bug", "enhancement", "automation"],
- });
- });
-
- it("should sanitize labels by removing problematic characters", async () => {
- setAgentOutput({
- items: [
- {
- type: "add_labels",
- labels: ["bug & more';
- const result = sanitizeContentFunction(input);
- expect(result).toContain("(script)");
- expect(result).toContain("(/script)");
- expect(result).toContain('"test"');
- expect(result).toContain("& more");
- });
-
- it("should handle self-closing XML tags without whitespace", () => {
- const input = 'Self-closing:
';
- const result = sanitizeContentFunction(input);
- expect(result).toContain("
"); // br is allowed
- expect(result).toContain('(img src="test.jpg"/)');
- expect(result).toContain('(meta charset="utf-8"/)');
- });
-
- it("should handle self-closing XML tags with whitespace", () => {
- const input = 'With spaces:
';
- const result = sanitizeContentFunction(input);
- expect(result).toContain("
"); // br is allowed
- expect(result).toContain('(img src="test.jpg" /)');
- expect(result).toContain('(meta charset="utf-8" /)');
- });
-
- it("should handle XML tags with various whitespace patterns", () => {
- const input = 'Various:
content
text';
- const result = sanitizeContentFunction(input);
- expect(result).toContain('(div\tclass="test")content(/div)');
- expect(result).toContain('(span\n id="test")text(/span)');
- });
-
- it("should preserve non-XML uses of < and > characters", () => {
- const input = "Math: x < y, array[5] > 3, and content
";
- const result = sanitizeContentFunction(input);
- expect(result).toContain("x < y");
- expect(result).toContain("5] > 3");
- expect(result).toContain("(div)content(/div)");
- });
-
- it("should handle mixed XML tags and comparison operators", () => {
- const input = "Compare: a < b and then plus c > d";
- const result = sanitizeContentFunction(input);
- expect(result).toContain("a < b");
- expect(result).toContain("(script)alert(1)(/script)");
- expect(result).toContain("c > d");
- });
-
- it("should block HTTP URLs while preserving HTTPS URLs", () => {
- const input = "HTTP: http://bad.com and HTTPS: https://github.com";
- const result = sanitizeContentFunction(input);
- expect(result).toContain("(redacted)"); // HTTP URL blocked
- expect(result).toContain("https://github.com"); // HTTPS URL preserved
- expect(result).not.toContain("http://bad.com");
- });
-
- it("should block various unsafe protocols", () => {
- const input = "Bad: ftp://file.com javascript:alert(1) file://local data:text/html,
-
-Special chars: \x00\x1F & "quotes" 'apostrophes'
- `.trim();
-
- const result = sanitizeContentFunction(input);
-
- // Check @mention neutralization
- expect(result).toContain("`@user`");
-
- // Check bot trigger neutralization
- expect(result).toContain("`fixes #123`");
-
- // Check URL filtering
- expect(result).toContain("(redacted)"); // HTTP and JavaScript URLs
- expect(result).toContain("https://github.com/repo");
- expect(result).not.toContain("http://bad.com");
- expect(result).not.toContain("javascript:alert");
-
- // Check XML tag conversion
- expect(result).toContain("(script)");
- expect(result).toContain('"quotes"');
- expect(result).toContain("'apostrophes'");
- expect(result).toContain("&");
-
- // Check control character removal
- expect(result).not.toContain("\x00");
- expect(result).not.toContain("\x1F");
- });
-
- it("should trim excessive whitespace", () => {
- const input = " \n\n Content with spacing \n\n ";
- const result = sanitizeContentFunction(input);
- expect(result).toBe("Content with spacing");
- });
-
- it("should handle empty environment variable gracefully", () => {
- // Clear GitHub environment variables to test empty domains behavior
- const originalServerUrl = process.env.GITHUB_SERVER_URL;
- const originalApiUrl = process.env.GITHUB_API_URL;
- delete process.env.GITHUB_SERVER_URL;
- delete process.env.GITHUB_API_URL;
-
- process.env.GH_AW_ALLOWED_DOMAINS = " , , ";
-
- const scriptWithExport = sanitizeScript.replace("await main();", "global.testSanitizeContent = sanitizeContent;");
- eval(scriptWithExport);
- const customSanitize = global.testSanitizeContent;
-
- const input = "Link: https://github.com/repo";
- const result = customSanitize(input);
- // With empty allowedDomains array, all HTTPS URLs get blocked
- expect(result).toContain("(redacted)");
- expect(result).not.toContain("https://github.com/repo");
-
- // Restore GitHub environment variables
- if (originalServerUrl) process.env.GITHUB_SERVER_URL = originalServerUrl;
- if (originalApiUrl) process.env.GITHUB_API_URL = originalApiUrl;
- });
-
- it("should handle @mentions with various formats", () => {
- const input = "Contact @user123, @org-name/team_name, @a, and @normalname";
- const result = sanitizeContentFunction(input);
- expect(result).toContain("`@user123`");
- expect(result).toContain("`@org-name/team_name`");
- expect(result).toContain("`@a`");
- expect(result).toContain("`@normalname`");
- });
-
- it("should not neutralize @mentions at start of backticked expressions", () => {
- const input = "Code: `@user.method()` and normal @user mention";
- const result = sanitizeContentFunction(input);
- expect(result).toContain("`@user.method()`"); // Should remain unchanged
- expect(result).toContain("`@user`"); // Should be neutralized
- });
-
- it("should handle various bot trigger phrase formats", () => {
- const input = "Fix #123, close #abc, FIXES #XYZ, resolves #456, fixes #789";
- const result = sanitizeContentFunction(input);
- expect(result).toContain("`Fix #123`");
- expect(result).toContain("`close #abc`");
- expect(result).toContain("`FIXES #XYZ`");
- expect(result).toContain("`resolves #456`"); // With space
- expect(result).toContain("`fixes #789`"); // Multiple spaces normalized to single
- });
-
- it("should handle edge cases in protocol filtering", () => {
- const input = `
- Protocols: HTTP://CAPS.COM, https://github.com/path?query=value#fragment
- More: mailto:user@domain.com tel:+1234567890 ssh://server:22/path
- Edge: ://malformed http:// https://
- Nested: (https://github.com) [http://bad.com] "ftp://files.com"
- `;
- const result = sanitizeContentFunction(input);
-
- // Check case insensitive protocol blocking
- expect(result).toContain("(redacted)"); // HTTP://CAPS.COM
- expect(result).toContain("https://github.com/path?query=value#fragment");
- expect(result).toContain("(redacted)"); // mailto, tel, ssh, http, ftp
- expect(result).not.toContain("HTTP://CAPS.COM");
- expect(result).not.toContain("mailto:user@domain.com");
- expect(result).not.toContain("tel:+1234567890");
- expect(result).not.toContain("ssh://server:22/path");
- });
-
- it("should preserve HTTPS URLs in various contexts", () => {
- const input = `
- Links in text: Visit https://github.com/user/repo for details.
- In parentheses: (https://github.io/docs)
- In brackets: [https://githubusercontent.com/file.txt]
- Multiple: https://github.com https://github.io https://githubassets.com
- `;
- const result = sanitizeContentFunction(input);
-
- expect(result).toContain("https://github.com/user/repo");
- expect(result).toContain("https://github.io/docs");
- expect(result).toContain("https://githubusercontent.com/file.txt");
- expect(result).toContain("https://github.com");
- expect(result).toContain("https://github.io");
- expect(result).toContain("https://githubassets.com");
- });
-
- it("should handle complex domain matching scenarios", () => {
- const input = `
- Valid: https://api.github.com/v4/graphql https://docs.github.com/en/
- Invalid: https://github.com.evil.com https://notgithub.com
- Edge: https://github.com.attacker.com https://sub.github.io.fake.com
- `;
- const result = sanitizeContentFunction(input);
-
- // Valid subdomains should be preserved
- expect(result).toContain("https://api.github.com/v4/graphql");
- expect(result).toContain("https://docs.github.com/en/");
-
- // Invalid domains should be blocked
- expect(result).toContain("(redacted)");
- expect(result).not.toContain("github.com.evil.com");
- expect(result).not.toContain("notgithub.com");
- expect(result).not.toContain("github.com.attacker.com");
- expect(result).not.toContain("sub.github.io.fake.com");
- });
-
- it("should handle URLs with special characters and edge cases", () => {
- const input = `
- URLs: https://github.com/user/repo-name_with.dots
- Query: https://github.com/search?q=test&type=code
- Fragment: https://github.com/user/repo#readme
- Port: https://github.dev:443/workspace
- Auth: https://github.com/repo (user info stripped by domain parsing)
- `;
- const result = sanitizeContentFunction(input);
-
- expect(result).toContain("https://github.com/user/repo-name_with.dots");
- expect(result).toContain("https://github.com/search?q=test&type=code"); // & not escaped
- expect(result).toContain("https://github.com/user/repo#readme");
- expect(result).toContain("https://github.dev:443/workspace");
- expect(result).toContain("https://github.com/repo");
- });
-
- it("should handle length truncation at exact boundary", () => {
- const exactLength = 524288;
- const input = "x".repeat(exactLength);
- const result = sanitizeContentFunction(input);
- expect(result.length).toBe(exactLength);
- expect(result).not.toContain("[Content truncated due to length]");
-
- const overLength = "x".repeat(exactLength + 100); // Significantly longer
- const overResult = sanitizeContentFunction(overLength);
- // The result should be truncated and contain the truncation message
- expect(overResult).toContain("[Content truncated due to length]");
- // The result should be shorter than the original due to truncation
- expect(overResult.length).toBeLessThan(overLength.length);
- });
-
- it("should handle line truncation at exact boundary", () => {
- const exactLines = 65000;
- // Create content with exactly 65000 lines (65000 newlines = 65001 elements when split)
- const input = Array(exactLines).fill("line").join("\n");
- const result = sanitizeContentFunction(input);
- const lines = result.split("\n");
- expect(lines.length).toBe(exactLines);
- expect(result).not.toContain("[Content truncated due to line count]");
-
- // Test with more than 65000 lines
- const overLines = Array(exactLines + 1)
- .fill("line")
- .join("\n");
- const overResult = sanitizeContentFunction(overLines);
- const overResultLines = overResult.split("\n");
- expect(overResultLines.length).toBeLessThanOrEqual(exactLines + 1); // +1 for truncation message
- expect(overResult).toContain("[Content truncated due to line count]");
- });
-
- it("should handle various ANSI escape sequence patterns", () => {
- const input = `
- Color: \x1b[31mRed\x1b[0m \x1b[1;32mBold Green\x1b[m
- Cursor: \x1b[2J\x1b[H Clear and home
- Other: \x1b[?25h Show cursor \x1b[K Clear line
- Complex: \x1b[38;5;196mTrueColor\x1b[0m
- `;
- const result = sanitizeContentFunction(input);
-
- expect(result).not.toContain("\x1b[");
- expect(result).toContain("Red");
- expect(result).toContain("Bold Green");
- expect(result).toContain("Clear and home");
- expect(result).toContain("Show cursor");
- expect(result).toContain("Clear line");
- expect(result).toContain("TrueColor");
- });
-
- it("should handle XML tag conversion in complex nested content", () => {
- const input = `
-
- alert("xss")]]>
-
-
- `;
- const result = sanitizeContentFunction(input);
-
- expect(result).toContain("(xml attr=\"value & 'quotes'\")");
- expect(result).toContain('(![CDATA[(script)alert("xss")(/script)]])');
- // XML comments are removed for security (to prevent content hiding)
- expect(result).not.toContain("comment with");
- expect(result).toContain("(/xml)");
- });
-
- it("should handle non-string inputs robustly", () => {
- expect(sanitizeContentFunction(123)).toBe("");
- expect(sanitizeContentFunction({})).toBe("");
- expect(sanitizeContentFunction([])).toBe("");
- expect(sanitizeContentFunction(true)).toBe("");
- expect(sanitizeContentFunction(false)).toBe("");
- });
-
- it("should preserve line breaks and tabs in content structure", () => {
- const input = `Line 1
-\t\tIndented line
-\n\nDouble newline
-
-\tTab at start`;
- const result = sanitizeContentFunction(input);
-
- expect(result).toContain("\n");
- expect(result).toContain("\t");
- expect(result.split("\n").length).toBeGreaterThan(1);
- expect(result).toContain("Line 1");
- expect(result).toContain("Indented line");
- expect(result).toContain("Tab at start");
- });
-
- it("should handle simultaneous protocol and domain filtering", () => {
- const input = `
- Good HTTPS: https://github.com/repo
- Bad HTTPS: https://evil.com/malware
- Bad HTTP allowed domain: http://github.com/repo
- Mixed: https://evil.com/path?goto=https://github.com/safe
- `;
- const result = sanitizeContentFunction(input);
-
- expect(result).toContain("https://github.com/repo");
- expect(result).toContain("(redacted)"); // For evil.com and http://github.com
- expect(result).not.toContain("https://evil.com");
- expect(result).not.toContain("http://github.com");
-
- // The safe URL in query param should still be preserved
- expect(result).toContain("https://github.com/safe");
- });
- });
-
- describe("main function", () => {
- beforeEach(() => {
- // Clean up any test files
- const testFile = "/tmp/gh-aw/test-output.txt";
- if (fs.existsSync(testFile)) {
- fs.unlinkSync(testFile);
- }
-
- // Make fs available globally for the evaluated script
- global.fs = fs;
- });
-
- afterEach(() => {
- // Clean up global fs
- delete global.fs;
- });
-
- it("should handle missing GH_AW_SAFE_OUTPUTS environment variable", async () => {
- delete process.env.GH_AW_SAFE_OUTPUTS;
-
- // Execute the script
- await eval(`(async () => { ${sanitizeScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("GH_AW_SAFE_OUTPUTS not set, no output to collect");
- expect(mockCore.setOutput).toHaveBeenCalledWith("output", "");
- });
-
- it("should handle non-existent output file", async () => {
- process.env.GH_AW_SAFE_OUTPUTS = "/tmp/gh-aw/non-existent-file.txt";
-
- // Execute the script
- await eval(`(async () => { ${sanitizeScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith(`Output file does not exist: ${"/tmp/gh-aw/non-existent-file.txt"}`);
- expect(mockCore.setOutput).toHaveBeenCalledWith("output", "");
- });
-
- it("should handle empty output file", async () => {
- const testFile = "/tmp/gh-aw/test-empty-output.txt";
- fs.writeFileSync(testFile, " \n \t \n ");
- process.env.GH_AW_SAFE_OUTPUTS = testFile;
-
- // Execute the script
- await eval(`(async () => { ${sanitizeScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("Output file is empty");
- expect(mockCore.setOutput).toHaveBeenCalledWith("output", "");
-
- fs.unlinkSync(testFile);
- });
-
- it("should process and sanitize output file content", async () => {
- const testContent = "Hello @user! This fixes #123. Link: http://bad.com and https://github.com/repo";
- const testFile = "/tmp/gh-aw/test-output.txt";
- fs.writeFileSync(testFile, testContent);
- process.env.GH_AW_SAFE_OUTPUTS = testFile;
-
- // Execute the script
- await eval(`(async () => { ${sanitizeScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith(expect.stringMatching(/Collected agentic output \(sanitized\):.*@user/));
-
- const outputCall = mockCore.setOutput.mock.calls.find(call => call[0] === "output");
- expect(outputCall).toBeDefined();
- const sanitizedOutput = outputCall[1];
-
- // Verify sanitization occurred
- expect(sanitizedOutput).toContain("`@user`");
- expect(sanitizedOutput).toContain("`fixes #123`");
- expect(sanitizedOutput).toContain("(redacted)"); // HTTP URL
- expect(sanitizedOutput).toContain("https://github.com/repo"); // HTTPS URL preserved
-
- fs.unlinkSync(testFile);
- });
-
- it("should truncate log output for very long content", async () => {
- const longContent = "x".repeat(250); // More than 200 chars to trigger truncation
- const testFile = "/tmp/gh-aw/test-long-output.txt";
- fs.writeFileSync(testFile, longContent);
- process.env.GH_AW_SAFE_OUTPUTS = testFile;
-
- // Execute the script
- await eval(`(async () => { ${sanitizeScript} })()`);
-
- const logCalls = mockCore.info.mock.calls;
- const outputLogCall = logCalls.find(call => call[0] && call[0].includes("Collected agentic output (sanitized):"));
-
- expect(outputLogCall).toBeDefined();
- expect(outputLogCall[0]).toContain("...");
- expect(outputLogCall[0].length).toBeLessThan(longContent.length);
-
- fs.unlinkSync(testFile);
- });
-
- it("should handle file read errors gracefully", async () => {
- // Create a file and then remove read permissions
- const testFile = "/tmp/gh-aw/test-no-read.txt";
- fs.writeFileSync(testFile, "test content");
-
- // Mock readFileSync to throw an error
- const originalReadFileSync = fs.readFileSync;
- const readFileSyncSpy = vi.spyOn(fs, "readFileSync").mockImplementation(() => {
- throw new Error("Permission denied");
- });
-
- process.env.GH_AW_SAFE_OUTPUTS = testFile;
-
- let thrownError = null;
- try {
- // Execute the script - it should throw but we catch it
- await eval(`(async () => { ${sanitizeScript} })()`);
- } catch (error) {
- thrownError = error;
- }
-
- expect(thrownError).toBeTruthy();
- expect(thrownError.message).toContain("Permission denied");
-
- // Restore spies
- readFileSyncSpy.mockRestore();
- // Clean up
- if (fs.existsSync(testFile)) {
- fs.unlinkSync(testFile);
- }
- });
-
- it("should handle binary file content", async () => {
- const binaryData = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe, 0xfd]);
- const testFile = "/tmp/gh-aw/test-binary.txt";
- fs.writeFileSync(testFile, binaryData);
- process.env.GH_AW_SAFE_OUTPUTS = testFile;
-
- // Execute the script
- await eval(`(async () => { ${sanitizeScript} })()`);
-
- // Should handle binary data gracefully
- const outputCall = mockCore.setOutput.mock.calls.find(call => call[0] === "output");
- expect(outputCall).toBeDefined();
-
- fs.unlinkSync(testFile);
- });
-
- it("should handle content with only whitespace", async () => {
- const whitespaceContent = " \n\n\t\t \r\n ";
- const testFile = "/tmp/gh-aw/test-whitespace.txt";
- fs.writeFileSync(testFile, whitespaceContent);
- process.env.GH_AW_SAFE_OUTPUTS = testFile;
-
- // Execute the script
- await eval(`(async () => { ${sanitizeScript} })()`);
-
- expect(mockCore.info).toHaveBeenCalledWith("Output file is empty");
- expect(mockCore.setOutput).toHaveBeenCalledWith("output", "");
-
- fs.unlinkSync(testFile);
- });
-
- it("should handle very large files with mixed content", async () => {
- // Create content that will trigger both length and line truncation
- const lineContent = 'This is a line with @user and https://evil.com plus \n';
- const repeatedContent = lineContent.repeat(70000); // Will exceed line limit
-
- const testFile = "/tmp/gh-aw/test-large-mixed.txt";
- fs.writeFileSync(testFile, repeatedContent);
- process.env.GH_AW_SAFE_OUTPUTS = testFile;
-
- // Execute the script
- await eval(`(async () => { ${sanitizeScript} })()`);
-
- const outputCall = mockCore.setOutput.mock.calls.find(call => call[0] === "output");
- expect(outputCall).toBeDefined();
- const result = outputCall[1];
-
- // Should be truncated (could be due to line count or length limit)
- expect(result).toMatch(/\[Content truncated due to (line count|length)\]/);
-
- // But should still sanitize what it processes
- expect(result).toContain("`@user`");
- expect(result).toContain("(redacted)"); // evil.com
- expect(result).toContain("(script)"); // XML tag conversion
-
- fs.unlinkSync(testFile);
- });
-
- it("should preserve log message format for short content", async () => {
- const shortContent = "Short message with @user";
- const testFile = "/tmp/gh-aw/test-short.txt";
- fs.writeFileSync(testFile, shortContent);
- process.env.GH_AW_SAFE_OUTPUTS = testFile;
-
- // Execute the script
- await eval(`(async () => { ${sanitizeScript} })()`);
-
- const logCalls = mockCore.info.mock.calls;
- const outputLogCall = logCalls.find(call => call[0] && call[0].includes("Collected agentic output (sanitized):"));
-
- expect(outputLogCall).toBeDefined();
- // Should not have ... for short content
- expect(outputLogCall[0]).not.toContain("...");
- expect(outputLogCall[0]).toContain("`@user`");
-
- fs.unlinkSync(testFile);
- });
- });
-
- describe("Command Neutralization", () => {
- beforeEach(() => {
- // Clear all mocks before each test
- vi.clearAllMocks();
- // Ensure test directory exists
- if (!fs.existsSync("/tmp/gh-aw")) {
- fs.mkdirSync("/tmp/gh-aw", { recursive: true });
- }
- });
-
- it("should neutralize command at the start of text", async () => {
- process.env.GH_AW_COMMAND = "test-bot";
- const content = "/test-bot please analyze this code";
- const testFile = "/tmp/gh-aw/test-command-start.txt";
- fs.writeFileSync(testFile, content);
- process.env.GH_AW_SAFE_OUTPUTS = testFile;
-
- // Execute the script
- await eval(`(async () => { ${sanitizeScript} })()`);
-
- const outputCall = mockCore.setOutput.mock.calls.find(call => call[0] === "output");
- expect(outputCall).toBeDefined();
- const result = outputCall[1];
-
- // Command should be neutralized with backticks
- expect(result).toContain("`/test-bot`");
- expect(result).not.toMatch(/^\/test-bot/); // Should not start with plain command
-
- fs.unlinkSync(testFile);
- delete process.env.GH_AW_COMMAND;
- });
-
- it("should not neutralize command when it appears later in text", async () => {
- process.env.GH_AW_COMMAND = "helper";
- const content = "I need help from /helper please";
- const testFile = "/tmp/gh-aw/test-command-middle.txt";
- fs.writeFileSync(testFile, content);
- process.env.GH_AW_SAFE_OUTPUTS = testFile;
-
- // Execute the script
- await eval(`(async () => { ${sanitizeScript} })()`);
-
- const outputCall = mockCore.setOutput.mock.calls.find(call => call[0] === "output");
- expect(outputCall).toBeDefined();
- const result = outputCall[1];
-
- // Command in the middle should not be neutralized
- expect(result).toContain("/helper");
- // The command should remain as is since it's not at the start
- expect(result).toContain("I need help from /helper please");
-
- fs.unlinkSync(testFile);
- delete process.env.GH_AW_COMMAND;
- });
-
- it("should handle command at start with leading whitespace", async () => {
- process.env.GH_AW_COMMAND = "review-bot";
- const content = " \n/review-bot analyze this PR";
- const testFile = "/tmp/gh-aw/test-command-whitespace.txt";
- fs.writeFileSync(testFile, content);
- process.env.GH_AW_SAFE_OUTPUTS = testFile;
-
- // Execute the script
- await eval(`(async () => { ${sanitizeScript} })()`);
-
- const outputCall = mockCore.setOutput.mock.calls.find(call => call[0] === "output");
- expect(outputCall).toBeDefined();
- const result = outputCall[1];
-
- // Command should be neutralized even with leading whitespace
- expect(result).toContain("`/review-bot`");
-
- fs.unlinkSync(testFile);
- delete process.env.GH_AW_COMMAND;
- });
-
- it("should not modify text when no command is configured", async () => {
- // No GH_AW_COMMAND set
- const content = "/some-bot do something";
- const testFile = "/tmp/gh-aw/test-no-command.txt";
- fs.writeFileSync(testFile, content);
- process.env.GH_AW_SAFE_OUTPUTS = testFile;
-
- // Execute the script
- await eval(`(async () => { ${sanitizeScript} })()`);
-
- const outputCall = mockCore.setOutput.mock.calls.find(call => call[0] === "output");
- expect(outputCall).toBeDefined();
- const result = outputCall[1];
-
- // Text should remain as is (no command neutralization)
- expect(result).toContain("/some-bot");
-
- fs.unlinkSync(testFile);
- });
-
- it("should handle special characters in command name", async () => {
- process.env.GH_AW_COMMAND = "test-bot_v2";
- const content = "/test-bot_v2 execute task";
- const testFile = "/tmp/gh-aw/test-special-chars.txt";
- fs.writeFileSync(testFile, content);
- process.env.GH_AW_SAFE_OUTPUTS = testFile;
-
- // Execute the script
- await eval(`(async () => { ${sanitizeScript} })()`);
-
- const outputCall = mockCore.setOutput.mock.calls.find(call => call[0] === "output");
- expect(outputCall).toBeDefined();
- const result = outputCall[1];
-
- // Command with special chars should be neutralized
- expect(result).toContain("`/test-bot_v2`");
-
- fs.unlinkSync(testFile);
- delete process.env.GH_AW_COMMAND;
- });
-
- it("should combine command neutralization with other sanitizations", async () => {
- process.env.GH_AW_COMMAND = "analyze-bot";
- const content = "/analyze-bot check @user for https://evil.com issues";
- const testFile = "/tmp/gh-aw/test-combined.txt";
- fs.writeFileSync(testFile, content);
- process.env.GH_AW_SAFE_OUTPUTS = testFile;
-
- // Execute the script
- await eval(`(async () => { ${sanitizeScript} })()`);
-
- const outputCall = mockCore.setOutput.mock.calls.find(call => call[0] === "output");
- expect(outputCall).toBeDefined();
- const result = outputCall[1];
-
- // Command should be neutralized
- expect(result).toContain("`/analyze-bot`");
- // @mention should be neutralized
- expect(result).toContain("`@user`");
- // Non-whitelisted domain should be redacted
- expect(result).toContain("(redacted)");
-
- fs.unlinkSync(testFile);
- delete process.env.GH_AW_COMMAND;
- });
- });
-
- describe("URL Redaction Logging", () => {
- beforeEach(() => {
- // Clear all mocks before each test
- vi.clearAllMocks();
- // Ensure test directory exists
- if (!fs.existsSync("/tmp/gh-aw")) {
- fs.mkdirSync("/tmp/gh-aw", { recursive: true });
- }
- });
-
- it("should log when HTTPS URLs with disallowed domains are redacted", async () => {
- const content = "Check out https://evil.com/malware for details";
- const testFile = "/tmp/gh-aw/test-url-logging-https.txt";
- fs.writeFileSync(testFile, content);
- process.env.GH_AW_SAFE_OUTPUTS = testFile;
-
- // Execute the script
- await eval(`(async () => { ${sanitizeScript} })()`);
-
- // Check that core.info was called with the truncated domain
- const infoCalls = mockCore.info.mock.calls;
- const redactionLog = infoCalls.find(call => call[0] && call[0].includes("Redacted URL: evil.com"));
-
- expect(redactionLog).toBeDefined();
- expect(redactionLog[0]).toBe("Redacted URL: evil.com");
-
- // Check that core.debug was called with the full URL
- const debugCalls = mockCore.debug.mock.calls;
- const fullUrlLog = debugCalls.find(call => call[0] && call[0].includes("Redacted URL (full): https://evil.com/malware"));
- expect(fullUrlLog).toBeDefined();
-
- fs.unlinkSync(testFile);
- });
-
- it("should log when HTTP URLs are redacted", async () => {
- const content = "Visit http://example.com for more info";
- const testFile = "/tmp/gh-aw/test-url-logging-http.txt";
- fs.writeFileSync(testFile, content);
- process.env.GH_AW_SAFE_OUTPUTS = testFile;
-
- // Execute the script
- await eval(`(async () => { ${sanitizeScript} })()`);
-
- // Check that core.info was called with the truncated domain
- const infoCalls = mockCore.info.mock.calls;
- const redactionLog = infoCalls.find(call => call[0] && call[0].includes("Redacted URL: example.com"));
-
- expect(redactionLog).toBeDefined();
- expect(redactionLog[0]).toBe("Redacted URL: example.com");
-
- // Check that core.debug was called with the full URL
- const debugCalls = mockCore.debug.mock.calls;
- const fullUrlLog = debugCalls.find(call => call[0] && call[0].includes("Redacted URL (full): http://example.com"));
- expect(fullUrlLog).toBeDefined();
-
- fs.unlinkSync(testFile);
- });
-
- it("should log when javascript: URLs are redacted", async () => {
- const content = "Click here: javascript:alert('xss')";
- const testFile = "/tmp/gh-aw/test-url-logging-js.txt";
- fs.writeFileSync(testFile, content);
- process.env.GH_AW_SAFE_OUTPUTS = testFile;
-
- // Execute the script
- await eval(`(async () => { ${sanitizeScript} })()`);
-
- // Check that core.info was called with truncated version
- // Note: The regex stops at '(' so it only captures "javascript:alert("
- const infoCalls = mockCore.info.mock.calls;
- const redactionLog = infoCalls.find(call => call[0] && call[0].includes("Redacted URL: javascript:a"));
-
- expect(redactionLog).toBeDefined();
- expect(redactionLog[0]).toBe("Redacted URL: javascript:a...");
-
- // Check that core.debug was called with the full captured URL
- const debugCalls = mockCore.debug.mock.calls;
- const fullUrlLog = debugCalls.find(call => call[0] && call[0].includes("Redacted URL (full): javascript:alert("));
- expect(fullUrlLog).toBeDefined();
-
- fs.unlinkSync(testFile);
- });
-
- it("should log multiple URL redactions", async () => {
- const content = "Links: http://bad1.com, https://bad2.com, ftp://bad3.com";
- const testFile = "/tmp/gh-aw/test-url-logging-multiple.txt";
- fs.writeFileSync(testFile, content);
- process.env.GH_AW_SAFE_OUTPUTS = testFile;
-
- // Execute the script
- await eval(`(async () => { ${sanitizeScript} })()`);
-
- // Check that core.info was called for each redacted URL (with truncated domains)
- const infoCalls = mockCore.info.mock.calls;
- const redactionLogs = infoCalls.filter(call => call[0] && call[0].startsWith("Redacted URL:"));
-
- expect(redactionLogs.length).toBeGreaterThanOrEqual(3);
- expect(redactionLogs.some(log => log[0].includes("bad1.com"))).toBe(true);
- expect(redactionLogs.some(log => log[0].includes("bad2.com"))).toBe(true);
- expect(redactionLogs.some(log => log[0].includes("bad3.com"))).toBe(true);
-
- fs.unlinkSync(testFile);
- });
-
- it("should not log when HTTPS URLs with allowed domains are preserved", async () => {
- const content = "Visit https://github.com for more info";
- const testFile = "/tmp/gh-aw/test-url-logging-allowed.txt";
- fs.writeFileSync(testFile, content);
- process.env.GH_AW_SAFE_OUTPUTS = testFile;
-
- // Execute the script
- await eval(`(async () => { ${sanitizeScript} })()`);
-
- // Check that core.info was NOT called for allowed URLs
- const infoCalls = mockCore.info.mock.calls;
- const redactionLogs = infoCalls.filter(call => call[0] && call[0].includes("Redacted URL: github.com"));
-
- expect(redactionLogs.length).toBe(0);
-
- fs.unlinkSync(testFile);
- });
-
- it("should log when data: URLs are redacted", async () => {
- const content = "Image: data:text/html,";
- const testFile = "/tmp/gh-aw/test-url-logging-data.txt";
- fs.writeFileSync(testFile, content);
- process.env.GH_AW_SAFE_OUTPUTS = testFile;
-
- // Execute the script
- await eval(`(async () => { ${sanitizeScript} })()`);
-
- // Check that core.info was called with truncated version
- // Note: The regex stops at '<' so it only captures "data:text/html,"
- const infoCalls = mockCore.info.mock.calls;
- const redactionLog = infoCalls.find(call => call[0] && call[0].includes("Redacted URL: data:text/ht"));
-
- expect(redactionLog).toBeDefined();
-
- fs.unlinkSync(testFile);
- });
-
- it("should handle mixed content with both redacted and allowed URLs", async () => {
- const content = "Good: https://github.com/repo Bad: https://evil.com/bad More: http://another.bad";
- const testFile = "/tmp/gh-aw/test-url-logging-mixed.txt";
- fs.writeFileSync(testFile, content);
- process.env.GH_AW_SAFE_OUTPUTS = testFile;
-
- // Execute the script
- await eval(`(async () => { ${sanitizeScript} })()`);
-
- // Check that core.info was called only for disallowed URLs (with truncated domains)
- const infoCalls = mockCore.info.mock.calls;
- const redactionLogs = infoCalls.filter(call => call[0] && call[0].startsWith("Redacted URL:"));
-
- expect(redactionLogs.length).toBeGreaterThanOrEqual(2);
- expect(redactionLogs.some(log => log[0].includes("evil.com"))).toBe(true);
- expect(redactionLogs.some(log => log[0].includes("another.bad"))).toBe(true);
- expect(redactionLogs.some(log => log[0].includes("github.com"))).toBe(false);
-
- fs.unlinkSync(testFile);
- });
- });
-});
+ (eval(scriptWithExport), (sanitizeContentFunction = global.testSanitizeContent));
+ }),
+ describe("sanitizeContent function", () => {
+ (it("should handle null and undefined inputs", () => {
+ (expect(sanitizeContentFunction(null)).toBe(""), expect(sanitizeContentFunction(void 0)).toBe(""), expect(sanitizeContentFunction("")).toBe(""));
+ }),
+ it("should neutralize @mentions by wrapping in backticks", () => {
+ const result = sanitizeContentFunction("Hello @user and @org/team");
+ (expect(result).toContain("`@user`"), expect(result).toContain("`@org/team`"));
+ }),
+ it("should not neutralize @mentions inside code blocks", () => {
+ const result = sanitizeContentFunction("Check `@user` in code and @realuser outside");
+ (expect(result).toContain("`@user`"), // Already in backticks, stays as is
+ expect(result).toContain("`@realuser`"));
+ }),
+ it("should neutralize bot trigger phrases", () => {
+ const result = sanitizeContentFunction("This fixes #123 and closes #456. Also resolves #789");
+ (expect(result).toContain("`fixes #123`"), expect(result).toContain("`closes #456`"), expect(result).toContain("`resolves #789`"));
+ }),
+ it("should remove control characters except newlines and tabs", () => {
+ const result = sanitizeContentFunction("Hello\0world\f\nNext line\tbad");
+ (expect(result).not.toContain("\0"), expect(result).not.toContain("\f"), expect(result).not.toContain(""), expect(result).toContain("\n"), expect(result).toContain("\t"));
+ }),
+ it("should convert XML tags to parentheses format", () => {
+ const result = sanitizeContentFunction('