From ee1584488f9b1386cdfc785d3cd081f16886c41b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 26 Dec 2025 13:35:34 +0000
Subject: [PATCH 1/6] Initial plan
From 0b689c89bb1f2b883312e584daf68c6ff9d30868 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 26 Dec 2025 14:02:33 +0000
Subject: [PATCH 2/6] Move JavaScript tests to actions/setup/js and update
build system
- Copied all 110 test files from pkg/workflow/js/ to actions/setup/js/
- Copied test configuration files (vitest.config.mjs, tsconfig.json, tsconfig.build.json, .prettierrc.json)
- Copied test support files (test-data directory, types directory, render_template.cjs, safe_outputs_tools.json, fuzz harnesses, safe_outputs_mcp_client.cjs)
- Created package.json and package-lock.json in actions/setup/js/ with all test dependencies
- Updated Makefile: changed all references from pkg/workflow/js to actions/setup/js for test-js, build-js, deps, fmt-cjs, lint-cjs targets
- Updated .github/workflows/ci.yml: changed cache-dependency-path and npm ci/test commands to use actions/setup/js
- Tests run successfully: 103/110 test files pass (2163/2271 individual tests pass)
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
.github/workflows/ci.yml | 14 +-
Makefile | 40 +-
actions/setup/js/.prettierrc.json | 27 +
actions/setup/js/add_comment.test.cjs | 451 ++++
.../setup/js/add_copilot_reviewer.test.cjs | 157 ++
actions/setup/js/add_labels.test.cjs | 436 ++++
.../js/add_reaction_and_edit_comment.test.cjs | 240 ++
actions/setup/js/add_reviewer.test.cjs | 303 +++
.../setup/js/assign_agent_helpers.test.cjs | 403 +++
actions/setup/js/assign_issue.test.cjs | 187 ++
actions/setup/js/assign_milestone.test.cjs | 125 +
.../setup/js/check_command_position.test.cjs | 115 +
actions/setup/js/check_membership.test.cjs | 292 +++
actions/setup/js/check_permissions.test.cjs | 154 ++
.../setup/js/check_permissions_utils.test.cjs | 225 ++
actions/setup/js/check_skip_if_match.test.cjs | 176 ++
actions/setup/js/check_stop_time.test.cjs | 101 +
actions/setup/js/check_team_member.test.cjs | 126 +
.../js/check_workflow_timestamp.test.cjs | 213 ++
actions/setup/js/checkout_pr_branch.test.cjs | 254 ++
actions/setup/js/close_discussion.test.cjs | 133 +
.../setup/js/close_entity_helpers.test.cjs | 187 ++
actions/setup/js/close_issue.test.cjs | 126 +
.../setup/js/close_older_discussions.test.cjs | 586 +++++
.../setup/js/collect_ndjson_output.test.cjs | 1638 +++++++++++++
actions/setup/js/compute_text.test.cjs | 312 +++
actions/setup/js/create_agent_task.test.cjs | 139 ++
.../js/create_code_scanning_alert.test.cjs | 207 ++
actions/setup/js/create_discussion.test.cjs | 179 ++
actions/setup/js/create_issue.test.cjs | 333 +++
.../js/create_pr_review_comment.test.cjs | 235 ++
actions/setup/js/create_pull_request.test.cjs | 736 ++++++
actions/setup/js/estimate_tokens.test.cjs | 52 +
actions/setup/js/fuzz_mentions_harness.cjs | 51 +
.../fuzz_sanitize_incoming_text_harness.cjs | 50 +
.../setup/js/fuzz_sanitize_label_harness.cjs | 49 +
.../setup/js/fuzz_sanitize_output_harness.cjs | 51 +
.../setup/js/generate_compact_schema.test.cjs | 86 +
actions/setup/js/generate_footer.test.cjs | 228 ++
actions/setup/js/generate_git_patch.test.cjs | 129 +
.../js/generate_safe_inputs_config.test.cjs | 76 +
actions/setup/js/get_base_branch.test.cjs | 54 +
actions/setup/js/get_current_branch.test.cjs | 96 +
actions/setup/js/get_repository_url.test.cjs | 118 +
actions/setup/js/get_tracker_id.test.cjs | 102 +
actions/setup/js/hide_comment.test.cjs | 131 +
actions/setup/js/interpolate_prompt.test.cjs | 124 +
.../js/interpolate_prompt_additional.test.cjs | 202 ++
actions/setup/js/is_truthy.test.cjs | 36 +
actions/setup/js/link_sub_issue.test.cjs | 131 +
actions/setup/js/load_agent_output.test.cjs | 248 ++
actions/setup/js/lock-issue.test.cjs | 90 +
.../setup/js/log_parser_bootstrap.test.cjs | 208 ++
actions/setup/js/log_parser_shared.test.cjs | 1943 +++++++++++++++
actions/setup/js/mcp_http_transport.test.cjs | 97 +
actions/setup/js/mcp_logger.test.cjs | 64 +
actions/setup/js/mcp_server_core.test.cjs | 900 +++++++
actions/setup/js/messages.test.cjs | 595 +++++
actions/setup/js/missing_tool.test.cjs | 194 ++
actions/setup/js/noop.test.cjs | 89 +
.../setup/js/normalize_branch_name.test.cjs | 82 +
.../setup/js/notify_comment_error.test.cjs | 264 ++
actions/setup/js/package-lock.json | 2178 +++++++++++++++++
actions/setup/js/package.json | 31 +
actions/setup/js/parse_claude_log.test.cjs | 472 ++++
actions/setup/js/parse_codex_log.test.cjs | 493 ++++
actions/setup/js/parse_copilot_log.test.cjs | 425 ++++
actions/setup/js/parse_firewall_logs.test.cjs | 157 ++
actions/setup/js/push_repo_memory.test.cjs | 358 +++
.../js/push_to_pull_request_branch.test.cjs | 475 ++++
actions/setup/js/read_buffer.test.cjs | 190 ++
actions/setup/js/redact_secrets.test.cjs | 136 +
.../setup/js/remove_duplicate_title.test.cjs | 184 ++
actions/setup/js/render_template.cjs | 87 +
actions/setup/js/render_template.test.cjs | 116 +
actions/setup/js/repo_helpers.test.cjs | 142 ++
actions/setup/js/resolve_mentions.test.cjs | 139 ++
actions/setup/js/runtime_import.test.cjs | 255 ++
.../setup/js/safe_inputs_bootstrap.test.cjs | 168 ++
.../js/safe_inputs_config_loader.test.cjs | 133 +
.../setup/js/safe_inputs_mcp_server.test.cjs | 848 +++++++
.../js/safe_inputs_mcp_server_http.test.cjs | 159 ++
.../js/safe_inputs_tool_factory.test.cjs | 70 +
.../setup/js/safe_inputs_validation.test.cjs | 138 ++
actions/setup/js/safe_output_helpers.test.cjs | 353 +++
.../setup/js/safe_output_processor.test.cjs | 385 +++
.../js/safe_output_type_validator.test.cjs | 468 ++++
.../setup/js/safe_output_validator.test.cjs | 283 +++
actions/setup/js/safe_outputs_append.test.cjs | 245 ++
.../setup/js/safe_outputs_bootstrap.test.cjs | 153 ++
.../js/safe_outputs_branch_detection.test.cjs | 42 +
actions/setup/js/safe_outputs_config.test.cjs | 167 ++
.../setup/js/safe_outputs_handlers.test.cjs | 266 ++
actions/setup/js/safe_outputs_mcp_client.cjs | 135 +
.../setup/js/safe_outputs_mcp_client.test.cjs | 105 +
.../safe_outputs_mcp_large_content.test.cjs | 240 ++
.../setup/js/safe_outputs_mcp_server.test.cjs | 184 ++
.../safe_outputs_mcp_server_defaults.test.cjs | 335 +++
.../js/safe_outputs_mcp_server_patch.test.cjs | 88 +
actions/setup/js/safe_outputs_tools.json | 634 +++++
.../js/safe_outputs_tools_loader.test.cjs | 348 +++
.../js/safe_outputs_type_validation.test.cjs | 53 +
actions/setup/js/sanitize_content.test.cjs | 706 ++++++
.../setup/js/sanitize_label_content.test.cjs | 90 +
actions/setup/js/sanitize_output.test.cjs | 565 +++++
.../setup/js/sanitize_workflow_name.test.cjs | 36 +
actions/setup/js/staged_preview.test.cjs | 306 +++
.../setup/js/substitute_placeholders.test.cjs | 52 +
actions/setup/js/temporary_id.test.cjs | 282 +++
actions/setup/js/test-7j9GSH/test.log | 1 +
actions/setup/js/test-9AX7fX/test.log | 1 +
actions/setup/js/test-GNKId1/test.log | 1 +
actions/setup/js/test-IRzCio/test.log | 1 +
actions/setup/js/test-IXfc6q/test.log | 1 +
actions/setup/js/test-SYvzgU/test.log | 1 +
actions/setup/js/test-data/FINDINGS.md | 103 +
actions/setup/js/test-data/README.md | 78 +
.../copilot-transformed-run-18296543175.md | 47 +
.../copilot-transformed-run-18296916269.md | 162 ++
actions/setup/js/test-i7RCld/test.log | 1 +
actions/setup/js/test-lq2w64/test.log | 1 +
actions/setup/js/tsconfig.build.json | 36 +
actions/setup/js/tsconfig.json | 62 +
actions/setup/js/types/github-script.d.ts | 90 +
.../setup/js/types/safe-outputs-config.d.ts | 290 +++
actions/setup/js/types/safe-outputs.d.ts | 367 +++
actions/setup/js/unlock-issue.test.cjs | 96 +
.../js/update_activation_comment.test.cjs | 154 ++
.../setup/js/update_context_helpers.test.cjs | 106 +
actions/setup/js/update_discussion.test.cjs | 550 +++++
actions/setup/js/update_issue.test.cjs | 127 +
.../js/update_pr_description_helpers.test.cjs | 399 +++
actions/setup/js/update_project.test.cjs | 647 +++++
actions/setup/js/update_pull_request.test.cjs | 740 ++++++
actions/setup/js/update_release.test.cjs | 126 +
actions/setup/js/update_runner.test.cjs | 608 +++++
actions/setup/js/upload_assets.test.cjs | 140 ++
actions/setup/js/validate_errors.test.cjs | 687 ++++++
actions/setup/js/vitest.config.mjs | 17 +
.../js/write_large_content_to_file.test.cjs | 117 +
140 files changed, 34995 insertions(+), 27 deletions(-)
create mode 100644 actions/setup/js/.prettierrc.json
create mode 100644 actions/setup/js/add_comment.test.cjs
create mode 100644 actions/setup/js/add_copilot_reviewer.test.cjs
create mode 100644 actions/setup/js/add_labels.test.cjs
create mode 100644 actions/setup/js/add_reaction_and_edit_comment.test.cjs
create mode 100644 actions/setup/js/add_reviewer.test.cjs
create mode 100644 actions/setup/js/assign_agent_helpers.test.cjs
create mode 100644 actions/setup/js/assign_issue.test.cjs
create mode 100644 actions/setup/js/assign_milestone.test.cjs
create mode 100644 actions/setup/js/check_command_position.test.cjs
create mode 100644 actions/setup/js/check_membership.test.cjs
create mode 100644 actions/setup/js/check_permissions.test.cjs
create mode 100644 actions/setup/js/check_permissions_utils.test.cjs
create mode 100644 actions/setup/js/check_skip_if_match.test.cjs
create mode 100644 actions/setup/js/check_stop_time.test.cjs
create mode 100644 actions/setup/js/check_team_member.test.cjs
create mode 100644 actions/setup/js/check_workflow_timestamp.test.cjs
create mode 100644 actions/setup/js/checkout_pr_branch.test.cjs
create mode 100644 actions/setup/js/close_discussion.test.cjs
create mode 100644 actions/setup/js/close_entity_helpers.test.cjs
create mode 100644 actions/setup/js/close_issue.test.cjs
create mode 100644 actions/setup/js/close_older_discussions.test.cjs
create mode 100644 actions/setup/js/collect_ndjson_output.test.cjs
create mode 100644 actions/setup/js/compute_text.test.cjs
create mode 100644 actions/setup/js/create_agent_task.test.cjs
create mode 100644 actions/setup/js/create_code_scanning_alert.test.cjs
create mode 100644 actions/setup/js/create_discussion.test.cjs
create mode 100644 actions/setup/js/create_issue.test.cjs
create mode 100644 actions/setup/js/create_pr_review_comment.test.cjs
create mode 100644 actions/setup/js/create_pull_request.test.cjs
create mode 100644 actions/setup/js/estimate_tokens.test.cjs
create mode 100644 actions/setup/js/fuzz_mentions_harness.cjs
create mode 100644 actions/setup/js/fuzz_sanitize_incoming_text_harness.cjs
create mode 100644 actions/setup/js/fuzz_sanitize_label_harness.cjs
create mode 100644 actions/setup/js/fuzz_sanitize_output_harness.cjs
create mode 100644 actions/setup/js/generate_compact_schema.test.cjs
create mode 100644 actions/setup/js/generate_footer.test.cjs
create mode 100644 actions/setup/js/generate_git_patch.test.cjs
create mode 100644 actions/setup/js/generate_safe_inputs_config.test.cjs
create mode 100644 actions/setup/js/get_base_branch.test.cjs
create mode 100644 actions/setup/js/get_current_branch.test.cjs
create mode 100644 actions/setup/js/get_repository_url.test.cjs
create mode 100644 actions/setup/js/get_tracker_id.test.cjs
create mode 100644 actions/setup/js/hide_comment.test.cjs
create mode 100644 actions/setup/js/interpolate_prompt.test.cjs
create mode 100644 actions/setup/js/interpolate_prompt_additional.test.cjs
create mode 100644 actions/setup/js/is_truthy.test.cjs
create mode 100644 actions/setup/js/link_sub_issue.test.cjs
create mode 100644 actions/setup/js/load_agent_output.test.cjs
create mode 100644 actions/setup/js/lock-issue.test.cjs
create mode 100644 actions/setup/js/log_parser_bootstrap.test.cjs
create mode 100644 actions/setup/js/log_parser_shared.test.cjs
create mode 100644 actions/setup/js/mcp_http_transport.test.cjs
create mode 100644 actions/setup/js/mcp_logger.test.cjs
create mode 100644 actions/setup/js/mcp_server_core.test.cjs
create mode 100644 actions/setup/js/messages.test.cjs
create mode 100644 actions/setup/js/missing_tool.test.cjs
create mode 100644 actions/setup/js/noop.test.cjs
create mode 100644 actions/setup/js/normalize_branch_name.test.cjs
create mode 100644 actions/setup/js/notify_comment_error.test.cjs
create mode 100644 actions/setup/js/package-lock.json
create mode 100644 actions/setup/js/package.json
create mode 100644 actions/setup/js/parse_claude_log.test.cjs
create mode 100644 actions/setup/js/parse_codex_log.test.cjs
create mode 100644 actions/setup/js/parse_copilot_log.test.cjs
create mode 100644 actions/setup/js/parse_firewall_logs.test.cjs
create mode 100644 actions/setup/js/push_repo_memory.test.cjs
create mode 100644 actions/setup/js/push_to_pull_request_branch.test.cjs
create mode 100644 actions/setup/js/read_buffer.test.cjs
create mode 100644 actions/setup/js/redact_secrets.test.cjs
create mode 100644 actions/setup/js/remove_duplicate_title.test.cjs
create mode 100644 actions/setup/js/render_template.cjs
create mode 100644 actions/setup/js/render_template.test.cjs
create mode 100644 actions/setup/js/repo_helpers.test.cjs
create mode 100644 actions/setup/js/resolve_mentions.test.cjs
create mode 100644 actions/setup/js/runtime_import.test.cjs
create mode 100644 actions/setup/js/safe_inputs_bootstrap.test.cjs
create mode 100644 actions/setup/js/safe_inputs_config_loader.test.cjs
create mode 100644 actions/setup/js/safe_inputs_mcp_server.test.cjs
create mode 100644 actions/setup/js/safe_inputs_mcp_server_http.test.cjs
create mode 100644 actions/setup/js/safe_inputs_tool_factory.test.cjs
create mode 100644 actions/setup/js/safe_inputs_validation.test.cjs
create mode 100644 actions/setup/js/safe_output_helpers.test.cjs
create mode 100644 actions/setup/js/safe_output_processor.test.cjs
create mode 100644 actions/setup/js/safe_output_type_validator.test.cjs
create mode 100644 actions/setup/js/safe_output_validator.test.cjs
create mode 100644 actions/setup/js/safe_outputs_append.test.cjs
create mode 100644 actions/setup/js/safe_outputs_bootstrap.test.cjs
create mode 100644 actions/setup/js/safe_outputs_branch_detection.test.cjs
create mode 100644 actions/setup/js/safe_outputs_config.test.cjs
create mode 100644 actions/setup/js/safe_outputs_handlers.test.cjs
create mode 100644 actions/setup/js/safe_outputs_mcp_client.cjs
create mode 100644 actions/setup/js/safe_outputs_mcp_client.test.cjs
create mode 100644 actions/setup/js/safe_outputs_mcp_large_content.test.cjs
create mode 100644 actions/setup/js/safe_outputs_mcp_server.test.cjs
create mode 100644 actions/setup/js/safe_outputs_mcp_server_defaults.test.cjs
create mode 100644 actions/setup/js/safe_outputs_mcp_server_patch.test.cjs
create mode 100644 actions/setup/js/safe_outputs_tools.json
create mode 100644 actions/setup/js/safe_outputs_tools_loader.test.cjs
create mode 100644 actions/setup/js/safe_outputs_type_validation.test.cjs
create mode 100644 actions/setup/js/sanitize_content.test.cjs
create mode 100644 actions/setup/js/sanitize_label_content.test.cjs
create mode 100644 actions/setup/js/sanitize_output.test.cjs
create mode 100644 actions/setup/js/sanitize_workflow_name.test.cjs
create mode 100644 actions/setup/js/staged_preview.test.cjs
create mode 100644 actions/setup/js/substitute_placeholders.test.cjs
create mode 100644 actions/setup/js/temporary_id.test.cjs
create mode 100644 actions/setup/js/test-7j9GSH/test.log
create mode 100644 actions/setup/js/test-9AX7fX/test.log
create mode 100644 actions/setup/js/test-GNKId1/test.log
create mode 100644 actions/setup/js/test-IRzCio/test.log
create mode 100644 actions/setup/js/test-IXfc6q/test.log
create mode 100644 actions/setup/js/test-SYvzgU/test.log
create mode 100644 actions/setup/js/test-data/FINDINGS.md
create mode 100644 actions/setup/js/test-data/README.md
create mode 100644 actions/setup/js/test-data/copilot-transformed-run-18296543175.md
create mode 100644 actions/setup/js/test-data/copilot-transformed-run-18296916269.md
create mode 100644 actions/setup/js/test-i7RCld/test.log
create mode 100644 actions/setup/js/test-lq2w64/test.log
create mode 100644 actions/setup/js/tsconfig.build.json
create mode 100644 actions/setup/js/tsconfig.json
create mode 100644 actions/setup/js/types/github-script.d.ts
create mode 100644 actions/setup/js/types/safe-outputs-config.d.ts
create mode 100644 actions/setup/js/types/safe-outputs.d.ts
create mode 100644 actions/setup/js/unlock-issue.test.cjs
create mode 100644 actions/setup/js/update_activation_comment.test.cjs
create mode 100644 actions/setup/js/update_context_helpers.test.cjs
create mode 100644 actions/setup/js/update_discussion.test.cjs
create mode 100644 actions/setup/js/update_issue.test.cjs
create mode 100644 actions/setup/js/update_pr_description_helpers.test.cjs
create mode 100644 actions/setup/js/update_project.test.cjs
create mode 100644 actions/setup/js/update_pull_request.test.cjs
create mode 100644 actions/setup/js/update_release.test.cjs
create mode 100644 actions/setup/js/update_runner.test.cjs
create mode 100644 actions/setup/js/upload_assets.test.cjs
create mode 100644 actions/setup/js/validate_errors.test.cjs
create mode 100644 actions/setup/js/vitest.config.mjs
create mode 100644 actions/setup/js/write_large_content_to_file.test.cjs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index db37467fea..053d50e646 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -284,7 +284,7 @@ jobs:
with:
node-version: "24"
cache: npm
- cache-dependency-path: pkg/workflow/js/package-lock.json
+ cache-dependency-path: actions/setup/js/package-lock.json
- name: Report Node cache status
run: |
if [ "${{ steps.setup-node.outputs.cache-hit }}" == "true" ]; then
@@ -307,7 +307,7 @@ jobs:
fi
- name: npm ci
run: npm ci
- working-directory: ./pkg/workflow/js
+ working-directory: ./actions/setup/js
- name: Build code
run: make build
@@ -332,7 +332,7 @@ jobs:
with:
node-version: "24"
cache: npm
- cache-dependency-path: pkg/workflow/js/package-lock.json
+ cache-dependency-path: actions/setup/js/package-lock.json
- name: Report Node cache status
run: |
if [ "${{ steps.setup-node.outputs.cache-hit }}" == "true" ]; then
@@ -341,9 +341,9 @@ jobs:
echo "⚠️ Node cache miss" >> $GITHUB_STEP_SUMMARY
fi
- name: Install npm dependencies
- run: cd pkg/workflow/js && npm ci
+ run: cd actions/setup/js && npm ci
- name: Run tests
- run: cd pkg/workflow/js && npm test
+ run: cd actions/setup/js && npm test
bench:
needs: [lint-go, lint-js]
# Only run benchmarks on main branch for performance tracking
@@ -471,7 +471,7 @@ jobs:
with:
node-version: "24"
cache: npm
- cache-dependency-path: pkg/workflow/js/package-lock.json
+ cache-dependency-path: actions/setup/js/package-lock.json
- name: Report Node cache status
run: |
@@ -482,7 +482,7 @@ jobs:
fi
- name: Install npm dependencies
- run: cd pkg/workflow/js && npm ci
+ run: cd actions/setup/js && npm ci
# JavaScript and JSON formatting checks
- name: Lint JavaScript files
diff --git a/Makefile b/Makefile
index 9be7728cb6..ad74c41cfe 100644
--- a/Makefile
+++ b/Makefile
@@ -151,11 +151,11 @@ security-trivy:
# Test JavaScript files
.PHONY: test-js
test-js: build-js
- cd pkg/workflow/js && npm run test:js -- --no-file-parallelism
+ cd actions/setup/js && npm run test:js -- --no-file-parallelism
.PHONY: build-js
build-js:
- cd pkg/workflow/js && npm run typecheck
+ cd actions/setup/js && npm run typecheck
# Bundle JavaScript files with local requires
.PHONY: bundle-js
@@ -283,7 +283,7 @@ license-report: ## Generate CSV license report
deps: check-node-version
go mod download
go mod tidy
- cd pkg/workflow/js && npm ci
+ cd actions/setup/js && npm ci
# Install development tools (including linter)
.PHONY: deps-dev
@@ -298,7 +298,7 @@ download-github-actions-schema:
@curl -s -o pkg/workflow/schemas/github-workflow.json \
"https://raw.githubusercontent.com/SchemaStore/schemastore/master/src/schemas/json/github-workflow.json"
@echo "Formatting schema with prettier..."
- @cd pkg/workflow/js && npm run format:schema >/dev/null 2>&1
+ @cd actions/setup/js && npm run format:schema >/dev/null 2>&1
@echo "✓ Downloaded and formatted GitHub Actions schema to pkg/workflow/schemas/github-workflow.json"
# Run linter (full repository scan)
@@ -348,15 +348,15 @@ fmt: fmt-go fmt-cjs fmt-json
fmt-go:
go fmt ./...
-# Format JavaScript (.cjs and .js) and JSON files in pkg/workflow/js directory
+# Format JavaScript (.cjs and .js) and JSON files in actions/setup/js directory
.PHONY: fmt-cjs
fmt-cjs:
- cd pkg/workflow/js && npm run format:cjs
+ cd actions/setup/js && npm run format:cjs
-# Format JSON files in pkg directory (excluding pkg/workflow/js, which is handled by npm script)
+# Format JSON files in pkg directory (excluding actions/setup/js, which is handled by npm script)
.PHONY: fmt-json
fmt-json:
- cd pkg/workflow/js && npm run format:pkg-json
+ cd actions/setup/js && npm run format:pkg-json
# Check formatting
.PHONY: fmt-check
@@ -366,25 +366,25 @@ fmt-check:
exit 1; \
fi
-# Check JavaScript (.cjs and .js) and JSON file formatting in pkg/workflow/js directory
+# Check JavaScript (.cjs and .js) and JSON file formatting in actions/setup/js directory
.PHONY: fmt-check-cjs
fmt-check-cjs:
- cd pkg/workflow/js && npm run lint:cjs
+ cd actions/setup/js && npm run lint:cjs
-# Check JSON file formatting in pkg directory (excluding pkg/workflow/js, which is handled by npm script)
+# Check JSON file formatting in pkg directory (excluding actions/setup/js, which is handled by npm script)
.PHONY: fmt-check-json
fmt-check-json:
- @if ! cd pkg/workflow/js && npm run check:pkg-json 2>&1 | grep -q "All matched files use Prettier code style"; then \
+ @if ! cd actions/setup/js && npm run check:pkg-json 2>&1 | grep -q "All matched files use Prettier code style"; then \
echo "JSON files are not formatted. Run 'make fmt-json' to fix."; \
exit 1; \
fi
-# Lint JavaScript (.cjs and .js) and JSON files in pkg/workflow/js directory
+# Lint JavaScript (.cjs and .js) and JSON files in actions/setup/js directory
.PHONY: lint-cjs
lint-cjs: fmt-check-cjs
@echo "✓ JavaScript formatting validated"
-# Lint JSON files in pkg directory (excluding pkg/workflow/js, which is handled by npm script)
+# Lint JSON files in pkg directory (excluding actions/setup/js, which is handled by npm script)
.PHONY: lint-json
lint-json: fmt-check-json
@echo "✓ JSON formatting validated"
@@ -558,13 +558,13 @@ help:
@echo " golint-incremental - Run golangci-lint incrementally (only changed files, requires BASE_REF)"
@echo " lint - Run linter"
@echo " fmt - Format code"
- @echo " fmt-cjs - Format JavaScript (.cjs and .js) and JSON files in pkg/workflow/js"
- @echo " fmt-json - Format JSON files in pkg directory (excluding pkg/workflow/js)"
+ @echo " fmt-cjs - Format JavaScript (.cjs and .js) and JSON files in actions/setup/js"
+ @echo " fmt-json - Format JSON files in pkg directory (excluding actions/setup/js)"
@echo " fmt-check - Check code formatting"
- @echo " fmt-check-cjs - Check JavaScript (.cjs) and JSON file formatting in pkg/workflow/js"
- @echo " fmt-check-json - Check JSON file formatting in pkg directory (excluding pkg/workflow/js)"
- @echo " lint-cjs - Lint JavaScript (.cjs) and JSON files in pkg/workflow/js"
- @echo " lint-json - Lint JSON files in pkg directory (excluding pkg/workflow/js)"
+ @echo " fmt-check-cjs - Check JavaScript (.cjs) and JSON file formatting in actions/setup/js"
+ @echo " fmt-check-json - Check JSON file formatting in pkg directory (excluding actions/setup/js)"
+ @echo " lint-cjs - Lint JavaScript (.cjs) and JSON files in actions/setup/js"
+ @echo " lint-json - Lint JSON files in pkg directory (excluding actions/setup/js)"
@echo " lint-errors - Lint error messages for quality compliance"
@echo " security-scan - Run all security scans (gosec, govulncheck, trivy)"
@echo " security-gosec - Run gosec Go security scanner"
diff --git a/actions/setup/js/.prettierrc.json b/actions/setup/js/.prettierrc.json
new file mode 100644
index 0000000000..0a7bde006d
--- /dev/null
+++ b/actions/setup/js/.prettierrc.json
@@ -0,0 +1,27 @@
+{
+ "parser": "typescript",
+ "semi": true,
+ "singleQuote": false,
+ "tabWidth": 2,
+ "trailingComma": "es5",
+ "printWidth": 240,
+ "bracketSpacing": true,
+ "arrowParens": "avoid",
+ "proseWrap": "never",
+ "bracketSameLine": true,
+ "singleAttributePerLine": false,
+ "overrides": [
+ {
+ "files": "*.cjs",
+ "options": {
+ "singleQuote": false
+ }
+ },
+ {
+ "files": "*.json",
+ "options": {
+ "parser": "json"
+ }
+ }
+ ]
+}
diff --git a/actions/setup/js/add_comment.test.cjs b/actions/setup/js/add_comment.test.cjs
new file mode 100644
index 0000000000..da0f7934ad
--- /dev/null
+++ b/actions/setup/js/add_comment.test.cjs
@@ -0,0 +1,451 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import fs from "fs";
+import path from "path";
+const mockCore = {
+ debug: vi.fn(),
+ info: vi.fn(),
+ notice: vi.fn(),
+ warning: vi.fn(),
+ error: vi.fn(),
+ setFailed: vi.fn(),
+ setOutput: vi.fn(),
+ exportVariable: vi.fn(),
+ setSecret: vi.fn(),
+ getInput: vi.fn(),
+ getBooleanInput: vi.fn(),
+ getMultilineInput: vi.fn(),
+ getState: vi.fn(),
+ saveState: vi.fn(),
+ startGroup: vi.fn(),
+ endGroup: vi.fn(),
+ group: vi.fn(),
+ addPath: vi.fn(),
+ setCommandEcho: vi.fn(),
+ isDebug: vi.fn().mockReturnValue(!1),
+ getIDToken: vi.fn(),
+ toPlatformPath: vi.fn(),
+ toPosixPath: vi.fn(),
+ toWin32Path: vi.fn(),
+ summary: { addRaw: vi.fn().mockReturnThis(), write: vi.fn().mockResolvedValue() },
+ },
+ mockGithub = { rest: { issues: { createComment: vi.fn() } } },
+ mockContext = { eventName: "issues", runId: 12345, repo: { owner: "testowner", repo: "testrepo" }, payload: { issue: { number: 123 }, repository: { html_url: "https://github.com/testowner/testrepo" } } };
+((global.core = mockCore),
+ (global.github = mockGithub),
+ (global.context = mockContext),
+ describe("add_comment.cjs", () => {
+ let createCommentScript, tempFilePath;
+ const setAgentOutput = data => {
+ tempFilePath = path.join("/tmp", `test_agent_output_${Date.now()}_${Math.random().toString(36).slice(2)}.json`);
+ const content = "string" == typeof data ? data : JSON.stringify(data);
+ (fs.writeFileSync(tempFilePath, content), (process.env.GH_AW_AGENT_OUTPUT = tempFilePath));
+ };
+ (beforeEach(() => {
+ (vi.clearAllMocks(), delete process.env.GH_AW_AGENT_OUTPUT, delete process.env.GITHUB_WORKFLOW, (global.context.eventName = "issues"), (global.context.payload.issue = { number: 123 }));
+ const scriptPath = path.join(process.cwd(), "add_comment.cjs");
+ createCommentScript = fs.readFileSync(scriptPath, "utf8");
+ }),
+ afterEach(() => {
+ tempFilePath && require("fs").existsSync(tempFilePath) && (require("fs").unlinkSync(tempFilePath), (tempFilePath = void 0));
+ }),
+ it("should skip when no agent output is provided", async () => {
+ (delete process.env.GH_AW_AGENT_OUTPUT,
+ await eval(`(async () => { ${createCommentScript}; await main(); })()`),
+ expect(mockCore.info).toHaveBeenCalledWith("No GH_AW_AGENT_OUTPUT environment variable found"),
+ expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled());
+ }),
+ it("should skip when agent output is empty", async () => {
+ (setAgentOutput(""),
+ await eval(`(async () => { ${createCommentScript}; await main(); })()`),
+ expect(mockCore.info).toHaveBeenCalledWith("Agent output content is empty"),
+ expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled());
+ }),
+ it("should skip when not in issue or PR context", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment content" }] }),
+ (global.context.eventName = "push"),
+ await eval(`(async () => { ${createCommentScript}; await main(); })()`),
+ expect(mockCore.info).toHaveBeenCalledWith('Target is "triggering" but not running in issue, pull request, or discussion context, skipping comment creation'),
+ expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled());
+ }),
+ it("should create comment on issue successfully", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment content" }] }), (global.context.eventName = "issues"));
+ const mockComment = { id: 456, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }),
+ await eval(`(async () => { ${createCommentScript}; await main(); })()`),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({ owner: "testowner", repo: "testrepo", issue_number: 123, body: expect.stringContaining("Test comment content") }),
+ expect(mockCore.setOutput).toHaveBeenCalledWith("comment_id", 456),
+ expect(mockCore.setOutput).toHaveBeenCalledWith("comment_url", mockComment.html_url),
+ expect(mockCore.summary.addRaw).toHaveBeenCalled(),
+ expect(mockCore.summary.write).toHaveBeenCalled());
+ }),
+ it("should create comment on pull request successfully", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test PR comment content" }] }), (global.context.eventName = "pull_request"), (global.context.payload.pull_request = { number: 789 }), delete global.context.payload.issue);
+ const mockComment = { id: 789, html_url: "https://github.com/testowner/testrepo/issues/789#issuecomment-789" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }),
+ await eval(`(async () => { ${createCommentScript}; await main(); })()`),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({ owner: "testowner", repo: "testrepo", issue_number: 789, body: expect.stringContaining("Test PR comment content") }));
+ }),
+ it("should include run information in comment body", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test content" }] }), (global.context.eventName = "issues"), (global.context.payload.issue = { number: 123 }));
+ const mockComment = { id: 456, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }),
+ await eval(`(async () => { ${createCommentScript}; await main(); })()`),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(),
+ expect(mockGithub.rest.issues.createComment.mock.calls).toHaveLength(1));
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ (expect(callArgs.body).toContain("Test content"), expect(callArgs.body).toContain("This treasure was crafted by"), expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345"));
+ }),
+ it("should include workflow source in footer when GH_AW_WORKFLOW_SOURCE is provided", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test content with source" }] }),
+ (process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"),
+ (process.env.GH_AW_WORKFLOW_SOURCE = "githubnext/agentics/workflows/ci-doctor.md@v1.0.0"),
+ (process.env.GH_AW_WORKFLOW_SOURCE_URL = "https://github.com/githubnext/agentics/tree/v1.0.0/workflows/ci-doctor.md"),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 123 }));
+ const mockComment = { id: 456, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript}; await main(); })()`), expect(mockGithub.rest.issues.createComment).toHaveBeenCalled());
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ (expect(callArgs.body).toContain("Test content with source"),
+ expect(callArgs.body).toContain("[🏴☠️ Test Workflow]"),
+ expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345"),
+ expect(callArgs.body).toContain("gh aw add githubnext/agentics/workflows/ci-doctor.md@v1.0.0"));
+ }),
+ it("should not include workflow source footer when GH_AW_WORKFLOW_SOURCE is not provided", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test content without source" }] }),
+ (process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"),
+ delete process.env.GH_AW_WORKFLOW_SOURCE,
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 123 }));
+ const mockComment = { id: 456, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript}; await main(); })()`), expect(mockGithub.rest.issues.createComment).toHaveBeenCalled());
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ (expect(callArgs.body).toContain("Test content without source"), expect(callArgs.body).toContain("[🏴☠️ Test Workflow]"), expect(callArgs.body).not.toContain("gh aw add"));
+ }),
+ it("should use GITHUB_SERVER_URL when repository context is not available", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test content with custom server" }] }),
+ (process.env.GITHUB_SERVER_URL = "https://github.enterprise.com"),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 123 }),
+ delete global.context.payload.repository);
+ const mockComment = { id: 456, html_url: "https://github.enterprise.com/testowner/testrepo/issues/123#issuecomment-456" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript}; await main(); })()`), expect(mockGithub.rest.issues.createComment).toHaveBeenCalled());
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ (expect(callArgs.body).toContain("Test content with custom server"),
+ expect(callArgs.body).toContain("https://github.enterprise.com/testowner/testrepo/actions/runs/12345"),
+ expect(callArgs.body).not.toContain("https://github.com/testowner/testrepo/actions/runs/12345"),
+ delete process.env.GITHUB_SERVER_URL);
+ }),
+ it("should fallback to https://github.com when GITHUB_SERVER_URL is not set and repository context is missing", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test content with fallback" }] }),
+ delete process.env.GITHUB_SERVER_URL,
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 123 }),
+ delete global.context.payload.repository);
+ const mockComment = { id: 456, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-456" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript}; await main(); })()`), expect(mockGithub.rest.issues.createComment).toHaveBeenCalled());
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ (expect(callArgs.body).toContain("Test content with fallback"), expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345"));
+ }),
+ it("should include triggering issue number in footer when in issue context", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Comment from issue context" }] }), (process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"), (global.context.eventName = "issues"), (global.context.payload.issue = { number: 42 }));
+ const mockComment = { id: 789, html_url: "https://github.com/testowner/testrepo/issues/42#issuecomment-789" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript}; await main(); })()`));
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ (expect(callArgs.body).toContain("Comment from issue context"), expect(callArgs.body).toContain("[🏴☠️ Test Workflow]"), expect(callArgs.body).toContain("#42"));
+ }),
+ it("should include triggering PR number in footer when in PR context", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Comment from PR context" }] }),
+ (process.env.GH_AW_WORKFLOW_NAME = "Test Workflow"),
+ (global.context.eventName = "pull_request"),
+ delete global.context.payload.issue,
+ (global.context.payload.pull_request = { number: 123 }));
+ const mockComment = { id: 890, html_url: "https://github.com/testowner/testrepo/pull/123#issuecomment-890" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript}; await main(); })()`));
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ (expect(callArgs.body).toContain("Comment from PR context"), expect(callArgs.body).toContain("[🏴☠️ Test Workflow]"), expect(callArgs.body).toContain("#123"), delete global.context.payload.pull_request);
+ }),
+ it("should use header level 4 for related items in comments", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment with related items" }] }),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 123 }),
+ (process.env.GH_AW_CREATED_ISSUE_URL = "https://github.com/testowner/testrepo/issues/456"),
+ (process.env.GH_AW_CREATED_ISSUE_NUMBER = "456"),
+ (process.env.GH_AW_CREATED_DISCUSSION_URL = "https://github.com/testowner/testrepo/discussions/789"),
+ (process.env.GH_AW_CREATED_DISCUSSION_NUMBER = "789"),
+ (process.env.GH_AW_CREATED_PULL_REQUEST_URL = "https://github.com/testowner/testrepo/pull/101"),
+ (process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER = "101"));
+ const mockComment = { id: 890, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-890" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript}; await main(); })()`));
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ (expect(callArgs.body).toContain("#### Related Items"),
+ expect(callArgs.body).toMatch(/####\s+Related Items/),
+ expect(callArgs.body).not.toMatch(/^##\s+Related Items/m),
+ expect(callArgs.body).not.toMatch(/\*\*Related Items:\*\*/),
+ expect(callArgs.body).toContain("- Issue: [#456](https://github.com/testowner/testrepo/issues/456)"),
+ expect(callArgs.body).toContain("- Discussion: [#789](https://github.com/testowner/testrepo/discussions/789)"),
+ expect(callArgs.body).toContain("- Pull Request: [#101](https://github.com/testowner/testrepo/pull/101)"),
+ delete process.env.GH_AW_CREATED_ISSUE_URL,
+ delete process.env.GH_AW_CREATED_ISSUE_NUMBER,
+ delete process.env.GH_AW_CREATED_DISCUSSION_URL,
+ delete process.env.GH_AW_CREATED_DISCUSSION_NUMBER,
+ delete process.env.GH_AW_CREATED_PULL_REQUEST_URL,
+ delete process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER);
+ }),
+ it("should use header level 4 for related items in staged mode preview", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment in staged mode" }] }),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 123 }),
+ (process.env.GH_AW_SAFE_OUTPUTS_STAGED = "true"),
+ (process.env.GH_AW_CREATED_ISSUE_URL = "https://github.com/testowner/testrepo/issues/456"),
+ (process.env.GH_AW_CREATED_ISSUE_NUMBER = "456"),
+ (process.env.GH_AW_CREATED_DISCUSSION_URL = "https://github.com/testowner/testrepo/discussions/789"),
+ (process.env.GH_AW_CREATED_DISCUSSION_NUMBER = "789"),
+ (process.env.GH_AW_CREATED_PULL_REQUEST_URL = "https://github.com/testowner/testrepo/pull/101"),
+ (process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER = "101"),
+ await eval(`(async () => { ${createCommentScript}; await main(); })()`),
+ expect(mockCore.summary.addRaw).toHaveBeenCalled());
+ const summaryContent = mockCore.summary.addRaw.mock.calls[0][0];
+ (expect(summaryContent).toContain("#### Related Items"),
+ expect(summaryContent).toMatch(/####\s+Related Items/),
+ expect(summaryContent).not.toMatch(/^##\s+Related Items/m),
+ expect(summaryContent).not.toMatch(/\*\*Related Items:\*\*/),
+ expect(summaryContent).toContain("- Issue: [#456](https://github.com/testowner/testrepo/issues/456)"),
+ expect(summaryContent).toContain("- Discussion: [#789](https://github.com/testowner/testrepo/discussions/789)"),
+ expect(summaryContent).toContain("- Pull Request: [#101](https://github.com/testowner/testrepo/pull/101)"),
+ delete process.env.GH_AW_SAFE_OUTPUTS_STAGED,
+ delete process.env.GH_AW_CREATED_ISSUE_URL,
+ delete process.env.GH_AW_CREATED_ISSUE_NUMBER,
+ delete process.env.GH_AW_CREATED_DISCUSSION_URL,
+ delete process.env.GH_AW_CREATED_DISCUSSION_NUMBER,
+ delete process.env.GH_AW_CREATED_PULL_REQUEST_URL,
+ delete process.env.GH_AW_CREATED_PULL_REQUEST_NUMBER);
+ }),
+ it("should create comment on discussion using GraphQL when in discussion_comment context", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test discussion comment" }] }),
+ (global.context.eventName = "discussion_comment"),
+ (global.context.payload.discussion = { number: 1993 }),
+ (global.context.payload.comment = { id: 12345, node_id: "DC_kwDOABcD1M4AaBbC" }),
+ delete global.context.payload.issue,
+ delete global.context.payload.pull_request);
+ const mockGraphqlResponse = vi.fn();
+ (mockGraphqlResponse.mockResolvedValueOnce({ repository: { discussion: { id: "D_kwDOPc1QR84BpqRs", url: "https://github.com/testowner/testrepo/discussions/1993" } } }).mockResolvedValueOnce({
+ addDiscussionComment: { comment: { id: "DC_kwDOPc1QR84BpqRt", body: "Test discussion comment", createdAt: "2025-10-19T22:00:00Z", url: "https://github.com/testowner/testrepo/discussions/1993#discussioncomment-123" } },
+ }),
+ (global.github.graphql = mockGraphqlResponse),
+ await eval(`(async () => { ${createCommentScript}; await main(); })()`),
+ expect(mockGraphqlResponse).toHaveBeenCalledTimes(2),
+ expect(mockGraphqlResponse.mock.calls[0][0]).toContain("query"),
+ expect(mockGraphqlResponse.mock.calls[0][0]).toContain("discussion(number: $num)"),
+ expect(mockGraphqlResponse.mock.calls[0][1]).toEqual({ owner: "testowner", repo: "testrepo", num: 1993 }),
+ expect(mockGraphqlResponse.mock.calls[1][0]).toContain("mutation"),
+ expect(mockGraphqlResponse.mock.calls[1][0]).toContain("addDiscussionComment"),
+ expect(mockGraphqlResponse.mock.calls[1][0]).toContain("replyToId"),
+ expect(mockGraphqlResponse.mock.calls[1][1].body).toContain("Test discussion comment"),
+ expect(mockGraphqlResponse.mock.calls[1][1].replyToId).toBe("DC_kwDOABcD1M4AaBbC"),
+ expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(),
+ expect(mockCore.setOutput).toHaveBeenCalledWith("comment_id", "DC_kwDOPc1QR84BpqRt"),
+ expect(mockCore.setOutput).toHaveBeenCalledWith("comment_url", "https://github.com/testowner/testrepo/discussions/1993#discussioncomment-123"),
+ delete global.github.graphql,
+ delete global.context.payload.discussion,
+ delete global.context.payload.comment);
+ }),
+ it("should create comment on discussion using GraphQL when GITHUB_AW_COMMENT_DISCUSSION is true (explicit discussion mode)", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test explicit discussion comment", item_number: 2001 }] }),
+ (process.env.GH_AW_COMMENT_TARGET = "*"),
+ (process.env.GITHUB_AW_COMMENT_DISCUSSION = "true"),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 123 }),
+ delete global.context.payload.discussion,
+ delete global.context.payload.pull_request);
+ const mockGraphqlResponse = vi.fn();
+ (mockGraphqlResponse.mockResolvedValueOnce({ repository: { discussion: { id: "D_kwDOPc1QR84BpqRu", url: "https://github.com/testowner/testrepo/discussions/2001" } } }).mockResolvedValueOnce({
+ addDiscussionComment: { comment: { id: "DC_kwDOPc1QR84BpqRv", body: "Test explicit discussion comment", createdAt: "2025-10-22T12:00:00Z", url: "https://github.com/testowner/testrepo/discussions/2001#discussioncomment-456" } },
+ }),
+ (global.github.graphql = mockGraphqlResponse),
+ await eval(`(async () => { ${createCommentScript}; await main(); })()`),
+ expect(mockGraphqlResponse).toHaveBeenCalledTimes(2),
+ expect(mockGraphqlResponse.mock.calls[0][0]).toContain("query"),
+ expect(mockGraphqlResponse.mock.calls[0][0]).toContain("discussion(number: $num)"),
+ expect(mockGraphqlResponse.mock.calls[0][1]).toEqual({ owner: "testowner", repo: "testrepo", num: 2001 }),
+ expect(mockGraphqlResponse.mock.calls[1][0]).toContain("mutation"),
+ expect(mockGraphqlResponse.mock.calls[1][0]).toContain("addDiscussionComment"),
+ expect(mockGraphqlResponse.mock.calls[1][1].body).toContain("Test explicit discussion comment"),
+ expect(mockGraphqlResponse.mock.calls[1][1].replyToId).toBeUndefined(),
+ expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(),
+ expect(mockCore.setOutput).toHaveBeenCalledWith("comment_id", "DC_kwDOPc1QR84BpqRv"),
+ expect(mockCore.setOutput).toHaveBeenCalledWith("comment_url", "https://github.com/testowner/testrepo/discussions/2001#discussioncomment-456"),
+ expect(mockCore.info).toHaveBeenCalledWith("Creating comment on discussion #2001"),
+ delete process.env.GH_AW_COMMENT_TARGET,
+ delete process.env.GITHUB_AW_COMMENT_DISCUSSION,
+ delete global.github.graphql);
+ }),
+ it("should replace temporary ID references in comment body using the temporary ID map", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "This comment references issue #aw_aabbccdd1122 which was created earlier." }] }),
+ (process.env.GH_AW_TEMPORARY_ID_MAP = JSON.stringify({ aw_aabbccdd1122: 456 })),
+ mockGithub.rest.issues.createComment.mockResolvedValue({ data: { id: 99999, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-99999" } }),
+ await eval(`(async () => { ${createCommentScript}; await main(); })()`),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith(expect.objectContaining({ body: expect.stringContaining("#456") })),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith(expect.objectContaining({ body: expect.not.stringContaining("#aw_aabbccdd1122") })),
+ delete process.env.GH_AW_TEMPORARY_ID_MAP);
+ }),
+ it("should load temporary ID map and log the count", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment" }] }),
+ (process.env.GH_AW_TEMPORARY_ID_MAP = JSON.stringify({ aw_abc123: 100, aw_def456: 200 })),
+ mockGithub.rest.issues.createComment.mockResolvedValue({ data: { id: 99999, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-99999" } }),
+ await eval(`(async () => { ${createCommentScript}; await main(); })()`),
+ expect(mockCore.info).toHaveBeenCalledWith("Loaded temporary ID map with 2 entries"),
+ delete process.env.GH_AW_TEMPORARY_ID_MAP);
+ }),
+ it("should handle empty temporary ID map gracefully", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Comment with #aw_000000000000 that won't be resolved" }] }),
+ (process.env.GH_AW_TEMPORARY_ID_MAP = "{}"),
+ mockGithub.rest.issues.createComment.mockResolvedValue({ data: { id: 99999, html_url: "https://github.com/testowner/testrepo/issues/123#issuecomment-99999" } }),
+ await eval(`(async () => { ${createCommentScript}; await main(); })()`),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith(expect.objectContaining({ body: expect.stringContaining("#aw_000000000000") })),
+ delete process.env.GH_AW_TEMPORARY_ID_MAP);
+ }),
+ it("should use custom footer message when GH_AW_SAFE_OUTPUT_MESSAGES is configured", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment with custom footer" }] }),
+ (process.env.GH_AW_WORKFLOW_NAME = "Custom Workflow"),
+ (process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ footer: "> Custom AI footer by [{workflow_name}]({run_url})", footerInstall: "> Custom install: `gh aw add {workflow_source}`" })),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 456 }));
+ const mockComment = { id: 999, html_url: "https://github.com/testowner/testrepo/issues/456#issuecomment-999" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript}; await main(); })()`));
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ (expect(callArgs.body).toContain("Test comment with custom footer"),
+ expect(callArgs.body).toContain("Custom AI footer by [Custom Workflow]"),
+ expect(callArgs.body).toContain("https://github.com/testowner/testrepo/actions/runs/12345"),
+ expect(callArgs.body).not.toContain("Ahoy!"),
+ expect(callArgs.body).not.toContain("treasure was crafted"),
+ delete process.env.GH_AW_SAFE_OUTPUT_MESSAGES);
+ }),
+ it("should use custom footer with install instructions when workflow source is provided", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Test comment with custom footer and install" }] }),
+ (process.env.GH_AW_WORKFLOW_NAME = "Custom Workflow"),
+ (process.env.GH_AW_WORKFLOW_SOURCE = "owner/repo/workflow.md@main"),
+ (process.env.GH_AW_WORKFLOW_SOURCE_URL = "https://github.com/owner/repo"),
+ (process.env.GH_AW_SAFE_OUTPUT_MESSAGES = JSON.stringify({ footer: "> Generated by [{workflow_name}]({run_url})", footerInstall: "> Install: `gh aw add {workflow_source}`" })),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 789 }));
+ const mockComment = { id: 1001, html_url: "https://github.com/testowner/testrepo/issues/789#issuecomment-1001" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockComment }), await eval(`(async () => { ${createCommentScript}; await main(); })()`));
+ const callArgs = mockGithub.rest.issues.createComment.mock.calls[0][0];
+ (expect(callArgs.body).toContain("Test comment with custom footer and install"),
+ expect(callArgs.body).toContain("Generated by [Custom Workflow]"),
+ expect(callArgs.body).toContain("Install: `gh aw add owner/repo/workflow.md@main`"),
+ expect(callArgs.body).not.toContain("Ahoy!"),
+ expect(callArgs.body).not.toContain("plunder this workflow"),
+ delete process.env.GH_AW_SAFE_OUTPUT_MESSAGES,
+ delete process.env.GH_AW_WORKFLOW_SOURCE,
+ delete process.env.GH_AW_WORKFLOW_SOURCE_URL);
+ }),
+ it("should hide older comments when hide-older-comments is enabled", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "New comment from workflow" }] }),
+ (process.env.GITHUB_WORKFLOW = "test-workflow-123"),
+ (process.env.GH_AW_HIDE_OLDER_COMMENTS = "true"),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 100 }),
+ (mockGithub.rest.issues.listComments = vi.fn().mockResolvedValue({
+ data: [
+ { id: 1, node_id: "IC_oldcomment1", body: "Old comment 1\n\n\x3c!-- workflow-id: test-workflow-123 --\x3e" },
+ { id: 2, node_id: "IC_oldcomment2", body: "Old comment 2\n\n\x3c!-- workflow-id: test-workflow-123 --\x3e" },
+ { id: 3, node_id: "IC_othercomment", body: "Comment from different workflow" },
+ ],
+ })),
+ (mockGithub.graphql = vi.fn().mockResolvedValue({ minimizeComment: { minimizedComment: { isMinimized: !0 } } })));
+ const mockNewComment = { id: 4, html_url: "https://github.com/testowner/testrepo/issues/100#issuecomment-4" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }),
+ await eval(`(async () => { ${createCommentScript}; await main(); })()`),
+ expect(mockGithub.rest.issues.listComments).toHaveBeenCalledWith({ owner: "testowner", repo: "testrepo", issue_number: 100, per_page: 100, page: 1 }),
+ expect(mockGithub.graphql).toHaveBeenCalledTimes(2),
+ expect(mockGithub.graphql).toHaveBeenCalledWith(expect.stringContaining("minimizeComment"), expect.objectContaining({ nodeId: "IC_oldcomment1", classifier: "OUTDATED" })),
+ expect(mockGithub.graphql).toHaveBeenCalledWith(expect.stringContaining("minimizeComment"), expect.objectContaining({ nodeId: "IC_oldcomment2", classifier: "OUTDATED" })),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith(expect.objectContaining({ owner: "testowner", repo: "testrepo", issue_number: 100, body: expect.stringContaining("New comment from workflow") })),
+ delete process.env.GITHUB_WORKFLOW,
+ delete process.env.GH_AW_HIDE_OLDER_COMMENTS);
+ }),
+ it("should not hide comments when hide-older-comments is not enabled", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "New comment without hiding" }] }),
+ (process.env.GITHUB_WORKFLOW = "test-workflow-456"),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 200 }),
+ (mockGithub.rest.issues.listComments = vi.fn()),
+ (mockGithub.graphql = vi.fn()));
+ const mockNewComment = { id: 5, html_url: "https://github.com/testowner/testrepo/issues/200#issuecomment-5" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }),
+ await eval(`(async () => { ${createCommentScript}; await main(); })()`),
+ expect(mockGithub.rest.issues.listComments).not.toHaveBeenCalled(),
+ expect(mockGithub.graphql).not.toHaveBeenCalled(),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(),
+ delete process.env.GITHUB_WORKFLOW);
+ }),
+ it("should skip hiding when workflow-id is not available", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "Comment without workflow-id" }] }),
+ (process.env.GH_AW_HIDE_OLDER_COMMENTS = "true"),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 300 }),
+ (mockGithub.rest.issues.listComments = vi.fn()),
+ (mockGithub.graphql = vi.fn()));
+ const mockNewComment = { id: 6, html_url: "https://github.com/testowner/testrepo/issues/300#issuecomment-6" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }),
+ await eval(`(async () => { ${createCommentScript}; await main(); })()`),
+ expect(mockGithub.rest.issues.listComments).not.toHaveBeenCalled(),
+ expect(mockGithub.graphql).not.toHaveBeenCalled(),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(),
+ delete process.env.GH_AW_HIDE_OLDER_COMMENTS);
+ }),
+ it("should respect allowed-reasons when hiding comments", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "New comment with allowed reasons" }] }),
+ (process.env.GITHUB_WORKFLOW = "test-workflow-789"),
+ (process.env.GH_AW_HIDE_OLDER_COMMENTS = "true"),
+ (process.env.GH_AW_ALLOWED_REASONS = JSON.stringify(["OUTDATED", "RESOLVED"])),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 400 }),
+ (mockGithub.rest.issues.listComments = vi.fn().mockResolvedValue({ data: [{ id: 1, node_id: "IC_oldcomment1", body: "Old comment\n\n\x3c!-- workflow-id: test-workflow-789 --\x3e" }] })),
+ (mockGithub.graphql = vi.fn().mockResolvedValue({ minimizeComment: { minimizedComment: { isMinimized: !0 } } })));
+ const mockNewComment = { id: 2, html_url: "https://github.com/testowner/testrepo/issues/400#issuecomment-2" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }),
+ await eval(`(async () => { ${createCommentScript}; await main(); })()`),
+ expect(mockGithub.graphql).toHaveBeenCalledWith(expect.stringContaining("minimizeComment"), expect.objectContaining({ nodeId: "IC_oldcomment1", classifier: "OUTDATED" })),
+ delete process.env.GITHUB_WORKFLOW,
+ delete process.env.GH_AW_HIDE_OLDER_COMMENTS,
+ delete process.env.GH_AW_ALLOWED_REASONS);
+ }),
+ it("should skip hiding when reason is not in allowed-reasons", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "New comment with restricted reasons" }] }),
+ (process.env.GITHUB_WORKFLOW = "test-workflow-999"),
+ (process.env.GH_AW_HIDE_OLDER_COMMENTS = "true"),
+ (process.env.GH_AW_ALLOWED_REASONS = JSON.stringify(["SPAM"])),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 500 }),
+ (mockGithub.rest.issues.listComments = vi.fn()),
+ (mockGithub.graphql = vi.fn()));
+ const mockNewComment = { id: 3, html_url: "https://github.com/testowner/testrepo/issues/500#issuecomment-3" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }),
+ await eval(`(async () => { ${createCommentScript}; await main(); })()`),
+ expect(mockGithub.rest.issues.listComments).not.toHaveBeenCalled(),
+ expect(mockGithub.graphql).not.toHaveBeenCalled(),
+ expect(mockGithub.rest.issues.createComment).toHaveBeenCalled(),
+ delete process.env.GITHUB_WORKFLOW,
+ delete process.env.GH_AW_HIDE_OLDER_COMMENTS,
+ delete process.env.GH_AW_ALLOWED_REASONS);
+ }),
+ it("should support lowercase allowed-reasons", async () => {
+ (setAgentOutput({ items: [{ type: "add_comment", body: "New comment with lowercase reasons" }] }),
+ (process.env.GITHUB_WORKFLOW = "test-workflow-lowercase"),
+ (process.env.GH_AW_HIDE_OLDER_COMMENTS = "true"),
+ (process.env.GH_AW_ALLOWED_REASONS = JSON.stringify(["outdated", "resolved"])),
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 600 }),
+ (mockGithub.rest.issues.listComments = vi.fn().mockResolvedValue({ data: [{ id: 1, node_id: "IC_oldcomment1", body: "Old comment\n\n\x3c!-- workflow-id: test-workflow-lowercase --\x3e" }] })),
+ (mockGithub.graphql = vi.fn().mockResolvedValue({ minimizeComment: { minimizedComment: { isMinimized: !0 } } })));
+ const mockNewComment = { id: 4, html_url: "https://github.com/testowner/testrepo/issues/600#issuecomment-4" };
+ (mockGithub.rest.issues.createComment.mockResolvedValue({ data: mockNewComment }),
+ await eval(`(async () => { ${createCommentScript}; await main(); })()`),
+ expect(mockGithub.graphql).toHaveBeenCalledWith(expect.stringContaining("minimizeComment"), expect.objectContaining({ nodeId: "IC_oldcomment1", classifier: "OUTDATED" })),
+ delete process.env.GITHUB_WORKFLOW,
+ delete process.env.GH_AW_HIDE_OLDER_COMMENTS,
+ delete process.env.GH_AW_ALLOWED_REASONS);
+ }));
+ }));
diff --git a/actions/setup/js/add_copilot_reviewer.test.cjs b/actions/setup/js/add_copilot_reviewer.test.cjs
new file mode 100644
index 0000000000..03ad132413
--- /dev/null
+++ b/actions/setup/js/add_copilot_reviewer.test.cjs
@@ -0,0 +1,157 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+
+// Mock the global objects that GitHub Actions provides
+const mockCore = {
+ debug: vi.fn(),
+ info: vi.fn(),
+ notice: vi.fn(),
+ warning: vi.fn(),
+ error: vi.fn(),
+ setFailed: vi.fn(),
+ setOutput: vi.fn(),
+ summary: {
+ addRaw: vi.fn().mockReturnThis(),
+ write: vi.fn().mockResolvedValue(),
+ },
+};
+
+const mockGithub = {
+ rest: {
+ pulls: {
+ requestReviewers: vi.fn().mockResolvedValue({}),
+ },
+ },
+};
+
+const mockContext = {
+ eventName: "pull_request",
+ repo: {
+ owner: "testowner",
+ repo: "testrepo",
+ },
+ payload: {
+ pull_request: {
+ number: 123,
+ },
+ },
+};
+
+// Set up global mocks before importing the module
+global.core = mockCore;
+global.github = mockGithub;
+global.context = mockContext;
+
+describe("add_copilot_reviewer", () => {
+ beforeEach(() => {
+ // Reset all mocks before each test
+ vi.clearAllMocks();
+ vi.resetModules(); // Reset module cache to allow fresh imports
+
+ // Clear environment variables
+ delete process.env.PR_NUMBER;
+
+ // Reset context to default
+ global.context = {
+ eventName: "pull_request",
+ repo: {
+ owner: "testowner",
+ repo: "testrepo",
+ },
+ payload: {
+ pull_request: {
+ number: 123,
+ },
+ },
+ };
+ });
+
+ // Helper function to run the script with main() call
+ async function runScript() {
+ const { main } = await import("./add_copilot_reviewer.cjs?" + Date.now());
+ await main();
+ }
+
+ it("should fail when PR_NUMBER is not set", async () => {
+ delete process.env.PR_NUMBER;
+
+ await runScript();
+
+ expect(mockCore.setFailed).toHaveBeenCalledWith("PR_NUMBER environment variable is required but not set");
+ expect(mockGithub.rest.pulls.requestReviewers).not.toHaveBeenCalled();
+ });
+
+ it("should fail when PR_NUMBER is empty", async () => {
+ process.env.PR_NUMBER = " ";
+
+ await runScript();
+
+ expect(mockCore.setFailed).toHaveBeenCalledWith("PR_NUMBER environment variable is required but not set");
+ expect(mockGithub.rest.pulls.requestReviewers).not.toHaveBeenCalled();
+ });
+
+ it("should fail when PR_NUMBER is not a valid number", async () => {
+ process.env.PR_NUMBER = "not-a-number";
+
+ await runScript();
+
+ expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Invalid PR_NUMBER"));
+ expect(mockGithub.rest.pulls.requestReviewers).not.toHaveBeenCalled();
+ });
+
+ it("should fail when PR_NUMBER is zero", async () => {
+ process.env.PR_NUMBER = "0";
+
+ await runScript();
+
+ expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Invalid PR_NUMBER"));
+ expect(mockGithub.rest.pulls.requestReviewers).not.toHaveBeenCalled();
+ });
+
+ it("should fail when PR_NUMBER is negative", async () => {
+ process.env.PR_NUMBER = "-1";
+
+ await runScript();
+
+ expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Invalid PR_NUMBER"));
+ expect(mockGithub.rest.pulls.requestReviewers).not.toHaveBeenCalled();
+ });
+
+ it("should add copilot as reviewer when PR_NUMBER is valid", async () => {
+ process.env.PR_NUMBER = "456";
+
+ await runScript();
+
+ expect(mockGithub.rest.pulls.requestReviewers).toHaveBeenCalledWith({
+ owner: "testowner",
+ repo: "testrepo",
+ pull_number: 456,
+ reviewers: ["copilot-pull-request-reviewer[bot]"],
+ });
+ expect(mockCore.info).toHaveBeenCalledWith("Successfully added Copilot as reviewer to PR #456");
+ expect(mockCore.summary.addRaw).toHaveBeenCalled();
+ expect(mockCore.summary.write).toHaveBeenCalled();
+ });
+
+ it("should handle API errors gracefully", async () => {
+ process.env.PR_NUMBER = "123";
+ mockGithub.rest.pulls.requestReviewers.mockRejectedValueOnce(new Error("API Error"));
+
+ await runScript();
+
+ expect(mockCore.error).toHaveBeenCalledWith(expect.stringContaining("Failed to add Copilot as reviewer"));
+ expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("Failed to add Copilot as reviewer"));
+ });
+
+ it("should trim whitespace from PR_NUMBER", async () => {
+ process.env.PR_NUMBER = " 789 ";
+
+ await runScript();
+
+ expect(mockGithub.rest.pulls.requestReviewers).toHaveBeenCalledWith({
+ owner: "testowner",
+ repo: "testrepo",
+ pull_number: 789,
+ reviewers: ["copilot-pull-request-reviewer[bot]"],
+ });
+ });
+});
diff --git a/actions/setup/js/add_labels.test.cjs b/actions/setup/js/add_labels.test.cjs
new file mode 100644
index 0000000000..60a649629f
--- /dev/null
+++ b/actions/setup/js/add_labels.test.cjs
@@ -0,0 +1,436 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import fs from "fs";
+import path from "path";
+const mockCore = {
+ debug: vi.fn(),
+ info: vi.fn(),
+ notice: vi.fn(),
+ warning: vi.fn(),
+ error: vi.fn(),
+ setFailed: vi.fn(),
+ setOutput: vi.fn(),
+ exportVariable: vi.fn(),
+ setSecret: vi.fn(),
+ getInput: vi.fn(),
+ getBooleanInput: vi.fn(),
+ getMultilineInput: vi.fn(),
+ getState: vi.fn(),
+ saveState: vi.fn(),
+ startGroup: vi.fn(),
+ endGroup: vi.fn(),
+ group: vi.fn(),
+ addPath: vi.fn(),
+ setCommandEcho: vi.fn(),
+ isDebug: vi.fn().mockReturnValue(!1),
+ getIDToken: vi.fn(),
+ toPlatformPath: vi.fn(),
+ toPosixPath: vi.fn(),
+ toWin32Path: vi.fn(),
+ summary: { addRaw: vi.fn().mockReturnThis(), write: vi.fn().mockResolvedValue() },
+ },
+ mockGithub = { rest: { issues: { addLabels: vi.fn() } } },
+ mockContext = { eventName: "issues", repo: { owner: "testowner", repo: "testrepo" }, payload: { issue: { number: 123 } } };
+((global.core = mockCore),
+ (global.github = mockGithub),
+ (global.context = mockContext),
+ describe("add_labels.cjs", () => {
+ let addLabelsScript, tempFilePath;
+ const setAgentOutput = data => {
+ tempFilePath = path.join("/tmp", `test_agent_output_${Date.now()}_${Math.random().toString(36).slice(2)}.json`);
+ const content = "string" == typeof data ? data : JSON.stringify(data);
+ (fs.writeFileSync(tempFilePath, content), (process.env.GH_AW_AGENT_OUTPUT = tempFilePath));
+ };
+ (beforeEach(() => {
+ (vi.clearAllMocks(),
+ delete process.env.GH_AW_AGENT_OUTPUT,
+ delete process.env.GH_AW_LABELS_ALLOWED,
+ delete process.env.GH_AW_LABELS_MAX_COUNT,
+ (global.context.eventName = "issues"),
+ (global.context.payload.issue = { number: 123 }),
+ delete global.context.payload.pull_request);
+ const scriptPath = path.join(process.cwd(), "add_labels.cjs");
+ addLabelsScript = fs.readFileSync(scriptPath, "utf8");
+ }),
+ afterEach(() => {
+ tempFilePath && require("fs").existsSync(tempFilePath) && (require("fs").unlinkSync(tempFilePath), (tempFilePath = void 0));
+ }),
+ 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,
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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"),
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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"),
+ mockGithub.rest.issues.addLabels.mockResolvedValue({}),
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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"),
+ mockGithub.rest.issues.addLabels.mockResolvedValue({}),
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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"),
+ mockGithub.rest.issues.addLabels.mockResolvedValue({}),
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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"] }));
+ }),
+ 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"),
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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"),
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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,
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ expect(mockCore.info).toHaveBeenCalledWith("Max count: 1"),
+ expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ owner: "testowner", repo: "testrepo", issue_number: 123, labels: ["bug"] }));
+ }));
+ }),
+ 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"),
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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"),
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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,
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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,
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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,
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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,
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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"),
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ owner: "testowner", repo: "testrepo", issue_number: 123, labels: ["bug", "enhancement"] }),
+ 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"),
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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"),
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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"),
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ owner: "testowner", repo: "testrepo", issue_number: 123, labels: ["bug", "enhancement"] }));
+ }),
+ 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"),
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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"] }));
+ }),
+ 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"),
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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"),
+ mockGithub.rest.issues.addLabels.mockResolvedValue({}),
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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({}),
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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(() => {});
+ (await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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(() => {});
+ (await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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"),
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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"),
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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"),
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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"),
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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"),
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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,"),
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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({}),
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ 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"),
+ mockGithub.rest.issues.addLabels.mockResolvedValue({}),
+ await eval(`(async () => { ${addLabelsScript}; await main(); })()`),
+ expect(mockGithub.rest.issues.addLabels).toHaveBeenCalledWith({ owner: "testowner", repo: "testrepo", issue_number: 123, labels: ["bug", "enhancement", "automation"] }));
+ }),
+ it("should sanitize labels by removing problematic characters", async () => {
+ (setAgentOutput({ items: [{ type: "add_labels", labels: ["bug]]>");
+ expect(result).toBe("(![CDATA[(script)alert('xss')(/script)]])");
+ });
+
+ it("should preserve inline formatting tags", () => {
+ const input = "This is bold, italic, and bold too text.";
+ const result = sanitizeContent(input);
+ expect(result).toBe(input);
+ });
+
+ it("should preserve list structure tags", () => {
+ const input = "
";
+ const result = sanitizeContent(input);
+ expect(result).toBe(input);
+ });
+
+ it("should preserve ordered list tags", () => {
+ const input = "- First
- Second
";
+ const result = sanitizeContent(input);
+ expect(result).toBe(input);
+ });
+
+ it("should preserve blockquote tags", () => {
+ const input = "This is a quote
";
+ const result = sanitizeContent(input);
+ expect(result).toBe(input);
+ });
+
+ it("should handle mixed allowed tags with formatting", () => {
+ const input = "This is bold and italic text.
New line here.
";
+ const result = sanitizeContent(input);
+ expect(result).toBe(input);
+ });
+
+ it("should handle nested list structure", () => {
+ const input = "";
+ const result = sanitizeContent(input);
+ expect(result).toBe(input);
+ });
+
+ it("should preserve details and summary tags", () => {
+ const result1 = sanitizeContent("content ");
+ expect(result1).toBe("content ");
+
+ const result2 = sanitizeContent("content");
+ expect(result2).toBe("content");
+ });
+
+ it("should convert removed tags that are no longer allowed", () => {
+ // Tag that was previously allowed but is now removed: u
+ const result3 = sanitizeContent("content");
+ expect(result3).toBe("(u)content(/u)");
+ });
+
+ it("should preserve heading tags h1-h6", () => {
+ const headings = ["h1", "h2", "h3", "h4", "h5", "h6"];
+ headings.forEach(tag => {
+ const input = `<${tag}>Heading${tag}>`;
+ const result = sanitizeContent(input);
+ expect(result).toBe(input);
+ });
+ });
+
+ it("should preserve hr tag", () => {
+ const result = sanitizeContent("Content before
Content after");
+ expect(result).toBe("Content before
Content after");
+ });
+
+ it("should preserve pre tag", () => {
+ const input = "Code block content
";
+ const result = sanitizeContent(input);
+ expect(result).toBe(input);
+ });
+
+ it("should preserve sub and sup tags", () => {
+ const input1 = "H2O";
+ const result1 = sanitizeContent(input1);
+ expect(result1).toBe(input1);
+
+ const input2 = "E=mc2";
+ const result2 = sanitizeContent(input2);
+ expect(result2).toBe(input2);
+ });
+
+ it("should preserve table structure tags", () => {
+ const input = "";
+ const result = sanitizeContent(input);
+ expect(result).toBe(input);
+ });
+ });
+
+ describe("ANSI escape sequence removal", () => {
+ it("should remove ANSI color codes", () => {
+ const result = sanitizeContent("\x1b[31mred text\x1b[0m");
+ expect(result).toBe("red text");
+ });
+
+ it("should remove various ANSI codes", () => {
+ const result = sanitizeContent("\x1b[1;32mBold Green\x1b[0m");
+ expect(result).toBe("Bold Green");
+ });
+ });
+
+ describe("control character removal", () => {
+ it("should remove control characters", () => {
+ const result = sanitizeContent("test\x00\x01\x02\x03content");
+ expect(result).toBe("testcontent");
+ });
+
+ it("should preserve newlines and tabs", () => {
+ const result = sanitizeContent("test\ncontent\twith\ttabs");
+ expect(result).toBe("test\ncontent\twith\ttabs");
+ });
+
+ it("should remove DEL character", () => {
+ const result = sanitizeContent("test\x7Fcontent");
+ expect(result).toBe("testcontent");
+ });
+ });
+
+ describe("URL protocol sanitization", () => {
+ it("should allow HTTPS URLs", () => {
+ const result = sanitizeContent("Visit https://github.com");
+ expect(result).toBe("Visit https://github.com");
+ });
+
+ it("should redact HTTP URLs", () => {
+ const result = sanitizeContent("Visit http://example.com");
+ expect(result).toContain("(redacted)");
+ expect(mockCore.info).toHaveBeenCalled();
+ });
+
+ it("should redact javascript: URLs", () => {
+ const result = sanitizeContent("Click javascript:alert('xss')");
+ expect(result).toContain("(redacted)");
+ });
+
+ it("should redact data: URLs", () => {
+ const result = sanitizeContent("Image data:image/png;base64,abc123");
+ expect(result).toContain("(redacted)");
+ });
+
+ it("should preserve file paths with colons", () => {
+ const result = sanitizeContent("C:\\path\\to\\file");
+ expect(result).toBe("C:\\path\\to\\file");
+ });
+
+ it("should preserve namespace patterns", () => {
+ const result = sanitizeContent("std::vector::push_back");
+ expect(result).toBe("std::vector::push_back");
+ });
+ });
+
+ describe("URL domain filtering", () => {
+ it("should allow default GitHub domains", () => {
+ const urls = ["https://github.com/repo", "https://api.github.com/endpoint", "https://raw.githubusercontent.com/file", "https://example.github.io/page"];
+
+ urls.forEach(url => {
+ const result = sanitizeContent(`Visit ${url}`);
+ expect(result).toBe(`Visit ${url}`);
+ });
+ });
+
+ it("should redact disallowed domains", () => {
+ const result = sanitizeContent("Visit https://evil.com/malicious");
+ expect(result).toContain("(redacted)");
+ expect(mockCore.info).toHaveBeenCalled();
+ });
+
+ it("should use custom allowed domains from environment", () => {
+ process.env.GH_AW_ALLOWED_DOMAINS = "example.com,trusted.net";
+ const result = sanitizeContent("Visit https://example.com/page");
+ expect(result).toBe("Visit https://example.com/page");
+ });
+
+ it("should extract and allow GitHub Enterprise domains", () => {
+ process.env.GITHUB_SERVER_URL = "https://github.company.com";
+ const result = sanitizeContent("Visit https://github.company.com/repo");
+ expect(result).toBe("Visit https://github.company.com/repo");
+ });
+
+ it("should allow subdomains of allowed domains", () => {
+ const result = sanitizeContent("Visit https://subdomain.github.com/page");
+ expect(result).toBe("Visit https://subdomain.github.com/page");
+ });
+
+ it("should log redacted domains", () => {
+ sanitizeContent("Visit https://verylongdomainnamefortest.com/page");
+ expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Redacted URL:"));
+ expect(mockCore.debug).toHaveBeenCalledWith(expect.stringContaining("Redacted URL (full):"));
+ });
+ });
+
+ describe("bot trigger neutralization", () => {
+ it("should neutralize 'fixes #123' patterns", () => {
+ const result = sanitizeContent("This fixes #123");
+ expect(result).toBe("This `fixes #123`");
+ });
+
+ it("should neutralize 'closes #456' patterns", () => {
+ const result = sanitizeContent("PR closes #456");
+ expect(result).toBe("PR `closes #456`");
+ });
+
+ it("should neutralize 'resolves #789' patterns", () => {
+ const result = sanitizeContent("This resolves #789");
+ expect(result).toBe("This `resolves #789`");
+ });
+
+ it("should handle various bot trigger verbs", () => {
+ const triggers = ["fix", "fixes", "close", "closes", "resolve", "resolves"];
+ triggers.forEach(verb => {
+ const result = sanitizeContent(`This ${verb} #123`);
+ expect(result).toBe(`This \`${verb} #123\``);
+ });
+ });
+
+ it("should neutralize alphanumeric issue references", () => {
+ const result = sanitizeContent("fixes #abc123def");
+ expect(result).toBe("`fixes #abc123def`");
+ });
+ });
+
+ describe("content truncation", () => {
+ it("should truncate content exceeding max length", () => {
+ const longContent = "x".repeat(600000);
+ const result = sanitizeContent(longContent);
+
+ expect(result.length).toBeLessThan(longContent.length);
+ expect(result).toContain("[Content truncated due to length]");
+ });
+
+ it("should truncate content exceeding max lines", () => {
+ const manyLines = Array(70000).fill("line").join("\n");
+ const result = sanitizeContent(manyLines);
+
+ expect(result.split("\n").length).toBeLessThan(70000);
+ expect(result).toContain("[Content truncated due to line count]");
+ });
+
+ it("should respect custom max length parameter", () => {
+ const content = "x".repeat(200);
+ const result = sanitizeContent(content, 100);
+
+ expect(result.length).toBeLessThanOrEqual(100 + 50); // +50 for truncation message
+ expect(result).toContain("[Content truncated");
+ });
+
+ it("should not truncate short content", () => {
+ const shortContent = "This is a short message";
+ const result = sanitizeContent(shortContent);
+
+ expect(result).toBe(shortContent);
+ expect(result).not.toContain("[Content truncated");
+ });
+ });
+
+ describe("combined sanitization", () => {
+ it("should apply all sanitizations correctly", () => {
+ const input = `
+
+ Hello @user, visit https://github.com
+
+ This fixes #123
+ \x1b[31mRed text\x1b[0m
+ `;
+
+ const result = sanitizeContent(input);
+
+ expect(result).not.toContain("");
+ expect(result).toContain("`@user`");
+ expect(result).toContain("https://github.com");
+ expect(result).not.toContain(""];
+
+ maliciousInputs.forEach(input => {
+ const result = sanitizeContent(input);
+ expect(result).not.toContain("
{
+ const input = "";
+ const result = sanitizeContent(input);
+
+ expect(result).toBe(input);
+ });
+ });
+
+ describe("edge cases", () => {
+ it("should handle empty string", () => {
+ expect(sanitizeContent("")).toBe("");
+ });
+
+ it("should handle whitespace-only input", () => {
+ expect(sanitizeContent(" \n\t ")).toBe("");
+ });
+
+ it("should handle content with only control characters", () => {
+ const result = sanitizeContent("\x00\x01\x02\x03");
+ expect(result).toBe("");
+ });
+
+ it("should handle content with multiple consecutive spaces", () => {
+ const result = sanitizeContent("hello world");
+ expect(result).toBe("hello world");
+ });
+
+ it("should handle Unicode characters", () => {
+ const result = sanitizeContent("Hello 世界 🌍");
+ expect(result).toBe("Hello 世界 🌍");
+ });
+
+ it("should handle URLs in query parameters", () => {
+ const input = "https://github.com/redirect?url=https://github.com/target";
+ const result = sanitizeContent(input);
+
+ expect(result).toContain("github.com");
+ expect(result).not.toContain("(redacted)");
+ });
+
+ it("should handle nested backticks", () => {
+ const result = sanitizeContent("Already `@user` and @other");
+ expect(result).toBe("Already `@user` and `@other`");
+ });
+ });
+
+ describe("redacted domains collection", () => {
+ let getRedactedDomains;
+ let clearRedactedDomains;
+ let writeRedactedDomainsLog;
+ const fs = require("fs");
+ const path = require("path");
+
+ beforeEach(async () => {
+ const module = await import("./sanitize_content.cjs");
+ getRedactedDomains = module.getRedactedDomains;
+ clearRedactedDomains = module.clearRedactedDomains;
+ writeRedactedDomainsLog = module.writeRedactedDomainsLog;
+ // Clear collected domains before each test
+ clearRedactedDomains();
+ });
+
+ it("should collect redacted HTTPS domains", () => {
+ sanitizeContent("Visit https://evil.com/malware");
+ const domains = getRedactedDomains();
+ expect(domains.length).toBe(1);
+ expect(domains[0]).toBe("evil.com");
+ });
+
+ it("should collect redacted HTTP domains", () => {
+ sanitizeContent("Visit http://example.com");
+ const domains = getRedactedDomains();
+ expect(domains.length).toBe(1);
+ expect(domains[0]).toBe("example.com");
+ });
+
+ it("should collect redacted dangerous protocols", () => {
+ sanitizeContent("Click javascript:alert(1)");
+ const domains = getRedactedDomains();
+ expect(domains.length).toBe(1);
+ expect(domains[0]).toBe("javascript:");
+ });
+
+ it("should collect multiple redacted domains", () => {
+ sanitizeContent("Visit https://bad1.com and http://bad2.com");
+ const domains = getRedactedDomains();
+ expect(domains.length).toBe(2);
+ expect(domains).toContain("bad1.com");
+ expect(domains).toContain("bad2.com");
+ });
+
+ it("should not collect allowed domains", () => {
+ sanitizeContent("Visit https://github.com/repo");
+ const domains = getRedactedDomains();
+ expect(domains.length).toBe(0);
+ });
+
+ it("should clear collected domains", () => {
+ sanitizeContent("Visit https://evil.com");
+ expect(getRedactedDomains().length).toBe(1);
+ clearRedactedDomains();
+ expect(getRedactedDomains().length).toBe(0);
+ });
+
+ it("should return a copy of domains array", () => {
+ sanitizeContent("Visit https://evil.com");
+ const domains1 = getRedactedDomains();
+ const domains2 = getRedactedDomains();
+ expect(domains1).not.toBe(domains2);
+ expect(domains1).toEqual(domains2);
+ });
+
+ describe("writeRedactedDomainsLog", () => {
+ const testDir = "/tmp/gh-aw-test-redacted";
+ const testFile = `${testDir}/redacted-urls.log`;
+
+ afterEach(() => {
+ // Clean up test files
+ if (fs.existsSync(testFile)) {
+ fs.unlinkSync(testFile);
+ }
+ if (fs.existsSync(testDir)) {
+ fs.rmSync(testDir, { recursive: true, force: true });
+ }
+ });
+
+ it("should return null when no domains collected", () => {
+ const result = writeRedactedDomainsLog(testFile);
+ expect(result).toBeNull();
+ expect(fs.existsSync(testFile)).toBe(false);
+ });
+
+ it("should write domains to log file", () => {
+ sanitizeContent("Visit https://evil.com/malware");
+ const result = writeRedactedDomainsLog(testFile);
+ expect(result).toBe(testFile);
+ expect(fs.existsSync(testFile)).toBe(true);
+
+ const content = fs.readFileSync(testFile, "utf8");
+ expect(content).toContain("evil.com");
+ // Should NOT contain the full URL, only the domain
+ expect(content).not.toContain("https://evil.com/malware");
+ });
+
+ it("should write multiple domains to log file", () => {
+ sanitizeContent("Visit https://bad1.com and http://bad2.com");
+ writeRedactedDomainsLog(testFile);
+
+ const content = fs.readFileSync(testFile, "utf8");
+ const lines = content.trim().split("\n");
+ expect(lines.length).toBe(2);
+ expect(content).toContain("bad1.com");
+ expect(content).toContain("bad2.com");
+ });
+
+ it("should create directory if it does not exist", () => {
+ const nestedFile = `${testDir}/nested/redacted-urls.log`;
+ sanitizeContent("Visit https://evil.com");
+ writeRedactedDomainsLog(nestedFile);
+ expect(fs.existsSync(nestedFile)).toBe(true);
+
+ // Clean up nested directory
+ fs.unlinkSync(nestedFile);
+ fs.rmdirSync(path.dirname(nestedFile));
+ });
+
+ it("should use default path when not specified", () => {
+ const defaultPath = "/tmp/gh-aw/redacted-urls.log";
+ sanitizeContent("Visit https://evil.com");
+ const result = writeRedactedDomainsLog();
+ expect(result).toBe(defaultPath);
+ expect(fs.existsSync(defaultPath)).toBe(true);
+
+ // Clean up
+ fs.unlinkSync(defaultPath);
+ });
+ });
+ });
+});
diff --git a/actions/setup/js/sanitize_label_content.test.cjs b/actions/setup/js/sanitize_label_content.test.cjs
new file mode 100644
index 0000000000..b07e343d1d
--- /dev/null
+++ b/actions/setup/js/sanitize_label_content.test.cjs
@@ -0,0 +1,90 @@
+import { describe, it, expect } from "vitest";
+const { sanitizeLabelContent } = require("./sanitize_label_content.cjs");
+describe("sanitize_label_content.cjs", () => {
+ describe("sanitizeLabelContent", () => {
+ (it("should return empty string for null input", () => {
+ expect(sanitizeLabelContent(null)).toBe("");
+ }),
+ it("should return empty string for undefined input", () => {
+ expect(sanitizeLabelContent(void 0)).toBe("");
+ }),
+ it("should return empty string for non-string input", () => {
+ (expect(sanitizeLabelContent(123)).toBe(""), expect(sanitizeLabelContent({})).toBe(""), expect(sanitizeLabelContent([])).toBe(""));
+ }),
+ it("should trim whitespace from input", () => {
+ (expect(sanitizeLabelContent(" test ")).toBe("test"), expect(sanitizeLabelContent("\n\ttest\n\t")).toBe("test"));
+ }),
+ it("should remove control characters", () => {
+ expect(sanitizeLabelContent("test\0\blabel")).toBe("testlabel");
+ }),
+ it("should remove DEL character (0x7F)", () => {
+ expect(sanitizeLabelContent("testlabel")).toBe("testlabel");
+ }),
+ it("should preserve newline character", () => {
+ expect(sanitizeLabelContent("test\nlabel")).toBe("test\nlabel");
+ }),
+ it("should remove ANSI escape codes", () => {
+ expect(sanitizeLabelContent("[31mred text[0m")).toBe("red text");
+ }),
+ it("should remove various ANSI codes", () => {
+ expect(sanitizeLabelContent("[1;32mBold Green[0m[4mUnderline[0m")).toBe("Bold GreenUnderline");
+ }),
+ it("should neutralize @mentions by wrapping in backticks", () => {
+ (expect(sanitizeLabelContent("Hello @user")).toBe("Hello `@user`"), expect(sanitizeLabelContent("@user said something")).toBe("`@user` said something"));
+ }),
+ it("should neutralize @org/team mentions", () => {
+ expect(sanitizeLabelContent("Hello @myorg/myteam")).toBe("Hello `@myorg/myteam`");
+ }),
+ it("should not neutralize @mentions already in backticks", () => {
+ expect(sanitizeLabelContent("Already `@user` handled")).toBe("Already `@user` handled");
+ }),
+ it("should neutralize multiple @mentions", () => {
+ expect(sanitizeLabelContent("@user1 and @user2 are here")).toBe("`@user1` and `@user2` are here");
+ }),
+ it("should remove HTML special characters", () => {
+ expect(sanitizeLabelContent("test<>&'\"label")).toBe("testlabel");
+ }),
+ it("should remove less-than signs", () => {
+ expect(sanitizeLabelContent("a < b")).toBe("a b");
+ }),
+ it("should remove greater-than signs", () => {
+ expect(sanitizeLabelContent("a > b")).toBe("a b");
+ }),
+ it("should remove ampersands", () => {
+ expect(sanitizeLabelContent("test & label")).toBe("test label");
+ }),
+ it("should remove single and double quotes", () => {
+ expect(sanitizeLabelContent('test\'s "label"')).toBe("tests label");
+ }),
+ it("should handle complex input with multiple sanitizations", () => {
+ expect(sanitizeLabelContent(" @user [31mred[0m test&label ")).toBe("`@user` red tag testlabel");
+ }),
+ it("should handle empty string input", () => {
+ expect(sanitizeLabelContent("")).toBe("");
+ }),
+ it("should handle whitespace-only input", () => {
+ expect(sanitizeLabelContent(" \n\t ")).toBe("");
+ }),
+ it("should preserve normal alphanumeric characters", () => {
+ (expect(sanitizeLabelContent("bug123")).toBe("bug123"), expect(sanitizeLabelContent("feature-request")).toBe("feature-request"));
+ }),
+ it("should preserve hyphens and underscores", () => {
+ expect(sanitizeLabelContent("test-label_123")).toBe("test-label_123");
+ }),
+ it("should handle consecutive control characters", () => {
+ expect(sanitizeLabelContent("test\0label")).toBe("testlabel");
+ }),
+ it("should handle @mentions at various positions", () => {
+ (expect(sanitizeLabelContent("start @user end")).toBe("start `@user` end"), expect(sanitizeLabelContent("@user at start")).toBe("`@user` at start"), expect(sanitizeLabelContent("at end @user")).toBe("at end `@user`"));
+ }),
+ it("should not treat email-like patterns as @mentions after alphanumerics", () => {
+ expect(sanitizeLabelContent("email@example.com")).toBe("email@example.com");
+ }),
+ it("should handle username edge cases", () => {
+ (expect(sanitizeLabelContent("@a")).toBe("`@a`"), expect(sanitizeLabelContent("@user-name-123")).toBe("`@user-name-123`"));
+ }),
+ it("should combine all sanitization rules correctly", () => {
+ expect(sanitizeLabelContent(' [31m@user[0m says & "goodbye" ')).toBe("`@user` says hello goodbye");
+ }));
+ });
+});
diff --git a/actions/setup/js/sanitize_output.test.cjs b/actions/setup/js/sanitize_output.test.cjs
new file mode 100644
index 0000000000..348f2b373a
--- /dev/null
+++ b/actions/setup/js/sanitize_output.test.cjs
@@ -0,0 +1,565 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import fs from "fs";
+import path from "path";
+const mockCore = {
+ debug: vi.fn(),
+ info: vi.fn(),
+ notice: vi.fn(),
+ warning: vi.fn(),
+ error: vi.fn(),
+ setFailed: vi.fn(),
+ setOutput: vi.fn(),
+ exportVariable: vi.fn(),
+ setSecret: vi.fn(),
+ getInput: vi.fn(),
+ getBooleanInput: vi.fn(),
+ getMultilineInput: vi.fn(),
+ getState: vi.fn(),
+ saveState: vi.fn(),
+ startGroup: vi.fn(),
+ endGroup: vi.fn(),
+ group: vi.fn(),
+ addPath: vi.fn(),
+ setCommandEcho: vi.fn(),
+ isDebug: vi.fn().mockReturnValue(!1),
+ getIDToken: vi.fn(),
+ toPlatformPath: vi.fn(),
+ toPosixPath: vi.fn(),
+ toWin32Path: vi.fn(),
+ summary: { addRaw: vi.fn().mockReturnThis(), write: vi.fn().mockResolvedValue() },
+};
+((global.core = mockCore),
+ describe("sanitize_output.cjs", () => {
+ let sanitizeScript, sanitizeContentFunction;
+ (beforeEach(() => {
+ (vi.clearAllMocks(), delete process.env.GH_AW_SAFE_OUTPUTS, delete process.env.GH_AW_ALLOWED_DOMAINS);
+ const scriptPath = path.join(process.cwd(), "sanitize_output.cjs");
+ sanitizeScript = fs.readFileSync(scriptPath, "utf8");
+ const scriptWithExport = sanitizeScript.replace("module.exports = { main };", "global.testSanitizeContent = sanitizeContent;");
+ (eval(scriptWithExport), (sanitizeContentFunction = global.testSanitizeContent));
+ }),
+ describe("sanitizeContent function", () => {
+ (it("should handle null and undefined inputs", () => {
+ (expect(sanitizeContentFunction(null)).toBe(""), expect(sanitizeContentFunction(void 0)).toBe(""), expect(sanitizeContentFunction("")).toBe(""));
+ }),
+ it("should neutralize @mentions by wrapping in backticks", () => {
+ const result = sanitizeContentFunction("Hello @user and @org/team");
+ (expect(result).toContain("`@user`"), expect(result).toContain("`@org/team`"));
+ }),
+ it("should not neutralize @mentions inside code blocks", () => {
+ const result = sanitizeContentFunction("Check `@user` in code and @realuser outside");
+ (expect(result).toContain("`@user`"), expect(result).toContain("`@realuser`"));
+ }),
+ it("should neutralize bot trigger phrases", () => {
+ const result = sanitizeContentFunction("This fixes #123 and closes #456. Also resolves #789");
+ (expect(result).toContain("`fixes #123`"), expect(result).toContain("`closes #456`"), expect(result).toContain("`resolves #789`"));
+ }),
+ it("should remove control characters except newlines and tabs", () => {
+ const result = sanitizeContentFunction("Hello\0world\f\nNext line\tbad");
+ (expect(result).not.toContain("\0"), expect(result).not.toContain("\f"), expect(result).not.toContain(""), expect(result).toContain("\n"), expect(result).toContain("\t"));
+ }),
+ it("should convert XML tags to parentheses format", () => {
+ const result = sanitizeContentFunction(']]>");
- expect(result).toBe("(![CDATA[(script)alert('xss')(/script)]])");
- });
-
- it("should preserve inline formatting tags", () => {
- const input = "This is bold, italic, and bold too text.";
- const result = sanitizeContent(input);
- expect(result).toBe(input);
- });
-
- it("should preserve list structure tags", () => {
- const input = "";
- const result = sanitizeContent(input);
- expect(result).toBe(input);
- });
-
- it("should preserve ordered list tags", () => {
- const input = "- First
- Second
";
- const result = sanitizeContent(input);
- expect(result).toBe(input);
- });
-
- it("should preserve blockquote tags", () => {
- const input = "This is a quote
";
- const result = sanitizeContent(input);
- expect(result).toBe(input);
- });
-
- it("should handle mixed allowed tags with formatting", () => {
- const input = "This is bold and italic text.
New line here.
";
- const result = sanitizeContent(input);
- expect(result).toBe(input);
- });
-
- it("should handle nested list structure", () => {
- const input = "";
- const result = sanitizeContent(input);
- expect(result).toBe(input);
- });
-
- it("should preserve details and summary tags", () => {
- const result1 = sanitizeContent("content ");
- expect(result1).toBe("content ");
-
- const result2 = sanitizeContent("content");
- expect(result2).toBe("content");
- });
-
- it("should convert removed tags that are no longer allowed", () => {
- // Tag that was previously allowed but is now removed: u
- const result3 = sanitizeContent("content");
- expect(result3).toBe("(u)content(/u)");
- });
-
- it("should preserve heading tags h1-h6", () => {
- const headings = ["h1", "h2", "h3", "h4", "h5", "h6"];
- headings.forEach(tag => {
- const input = `<${tag}>Heading${tag}>`;
- const result = sanitizeContent(input);
- expect(result).toBe(input);
- });
- });
-
- it("should preserve hr tag", () => {
- const result = sanitizeContent("Content before
Content after");
- expect(result).toBe("Content before
Content after");
- });
-
- it("should preserve pre tag", () => {
- const input = "Code block content
";
- const result = sanitizeContent(input);
- expect(result).toBe(input);
- });
-
- it("should preserve sub and sup tags", () => {
- const input1 = "H2O";
- const result1 = sanitizeContent(input1);
- expect(result1).toBe(input1);
-
- const input2 = "E=mc2";
- const result2 = sanitizeContent(input2);
- expect(result2).toBe(input2);
- });
-
- it("should preserve table structure tags", () => {
- const input = "";
- const result = sanitizeContent(input);
- expect(result).toBe(input);
- });
- });
-
- describe("ANSI escape sequence removal", () => {
- it("should remove ANSI color codes", () => {
- const result = sanitizeContent("\x1b[31mred text\x1b[0m");
- expect(result).toBe("red text");
- });
-
- it("should remove various ANSI codes", () => {
- const result = sanitizeContent("\x1b[1;32mBold Green\x1b[0m");
- expect(result).toBe("Bold Green");
- });
- });
-
- describe("control character removal", () => {
- it("should remove control characters", () => {
- const result = sanitizeContent("test\x00\x01\x02\x03content");
- expect(result).toBe("testcontent");
- });
-
- it("should preserve newlines and tabs", () => {
- const result = sanitizeContent("test\ncontent\twith\ttabs");
- expect(result).toBe("test\ncontent\twith\ttabs");
- });
-
- it("should remove DEL character", () => {
- const result = sanitizeContent("test\x7Fcontent");
- expect(result).toBe("testcontent");
- });
- });
-
- describe("URL protocol sanitization", () => {
- it("should allow HTTPS URLs", () => {
- const result = sanitizeContent("Visit https://github.com");
- expect(result).toBe("Visit https://github.com");
- });
-
- it("should redact HTTP URLs", () => {
- const result = sanitizeContent("Visit http://example.com");
- expect(result).toContain("(redacted)");
- expect(mockCore.info).toHaveBeenCalled();
- });
-
- it("should redact javascript: URLs", () => {
- const result = sanitizeContent("Click javascript:alert('xss')");
- expect(result).toContain("(redacted)");
- });
-
- it("should redact data: URLs", () => {
- const result = sanitizeContent("Image data:image/png;base64,abc123");
- expect(result).toContain("(redacted)");
- });
-
- it("should preserve file paths with colons", () => {
- const result = sanitizeContent("C:\\path\\to\\file");
- expect(result).toBe("C:\\path\\to\\file");
- });
-
- it("should preserve namespace patterns", () => {
- const result = sanitizeContent("std::vector::push_back");
- expect(result).toBe("std::vector::push_back");
- });
- });
-
- describe("URL domain filtering", () => {
- it("should allow default GitHub domains", () => {
- const urls = ["https://github.com/repo", "https://api.github.com/endpoint", "https://raw.githubusercontent.com/file", "https://example.github.io/page"];
-
- urls.forEach(url => {
- const result = sanitizeContent(`Visit ${url}`);
- expect(result).toBe(`Visit ${url}`);
- });
- });
-
- it("should redact disallowed domains", () => {
- const result = sanitizeContent("Visit https://evil.com/malicious");
- expect(result).toContain("(redacted)");
- expect(mockCore.info).toHaveBeenCalled();
- });
-
- it("should use custom allowed domains from environment", () => {
- process.env.GH_AW_ALLOWED_DOMAINS = "example.com,trusted.net";
- const result = sanitizeContent("Visit https://example.com/page");
- expect(result).toBe("Visit https://example.com/page");
- });
-
- it("should extract and allow GitHub Enterprise domains", () => {
- process.env.GITHUB_SERVER_URL = "https://github.company.com";
- const result = sanitizeContent("Visit https://github.company.com/repo");
- expect(result).toBe("Visit https://github.company.com/repo");
- });
-
- it("should allow subdomains of allowed domains", () => {
- const result = sanitizeContent("Visit https://subdomain.github.com/page");
- expect(result).toBe("Visit https://subdomain.github.com/page");
- });
-
- it("should log redacted domains", () => {
- sanitizeContent("Visit https://verylongdomainnamefortest.com/page");
- expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Redacted URL:"));
- expect(mockCore.debug).toHaveBeenCalledWith(expect.stringContaining("Redacted URL (full):"));
- });
- });
-
- describe("bot trigger neutralization", () => {
- it("should neutralize 'fixes #123' patterns", () => {
- const result = sanitizeContent("This fixes #123");
- expect(result).toBe("This `fixes #123`");
- });
-
- it("should neutralize 'closes #456' patterns", () => {
- const result = sanitizeContent("PR closes #456");
- expect(result).toBe("PR `closes #456`");
- });
-
- it("should neutralize 'resolves #789' patterns", () => {
- const result = sanitizeContent("This resolves #789");
- expect(result).toBe("This `resolves #789`");
- });
-
- it("should handle various bot trigger verbs", () => {
- const triggers = ["fix", "fixes", "close", "closes", "resolve", "resolves"];
- triggers.forEach(verb => {
- const result = sanitizeContent(`This ${verb} #123`);
- expect(result).toBe(`This \`${verb} #123\``);
- });
- });
-
- it("should neutralize alphanumeric issue references", () => {
- const result = sanitizeContent("fixes #abc123def");
- expect(result).toBe("`fixes #abc123def`");
- });
- });
-
- describe("content truncation", () => {
- it("should truncate content exceeding max length", () => {
- const longContent = "x".repeat(600000);
- const result = sanitizeContent(longContent);
-
- expect(result.length).toBeLessThan(longContent.length);
- expect(result).toContain("[Content truncated due to length]");
- });
-
- it("should truncate content exceeding max lines", () => {
- const manyLines = Array(70000).fill("line").join("\n");
- const result = sanitizeContent(manyLines);
-
- expect(result.split("\n").length).toBeLessThan(70000);
- expect(result).toContain("[Content truncated due to line count]");
- });
-
- it("should respect custom max length parameter", () => {
- const content = "x".repeat(200);
- const result = sanitizeContent(content, 100);
-
- expect(result.length).toBeLessThanOrEqual(100 + 50); // +50 for truncation message
- expect(result).toContain("[Content truncated");
- });
-
- it("should not truncate short content", () => {
- const shortContent = "This is a short message";
- const result = sanitizeContent(shortContent);
-
- expect(result).toBe(shortContent);
- expect(result).not.toContain("[Content truncated");
- });
- });
-
- describe("combined sanitization", () => {
- it("should apply all sanitizations correctly", () => {
- const input = `
-
- Hello @user, visit https://github.com
-
- This fixes #123
- \x1b[31mRed text\x1b[0m
- `;
-
- const result = sanitizeContent(input);
-
- expect(result).not.toContain("");
- expect(result).toContain("`@user`");
- expect(result).toContain("https://github.com");
- expect(result).not.toContain(""];
-
- maliciousInputs.forEach(input => {
- const result = sanitizeContent(input);
- expect(result).not.toContain("
{
- const input = "";
- const result = sanitizeContent(input);
-
- expect(result).toBe(input);
- });
- });
-
- describe("edge cases", () => {
- it("should handle empty string", () => {
- expect(sanitizeContent("")).toBe("");
- });
-
- it("should handle whitespace-only input", () => {
- expect(sanitizeContent(" \n\t ")).toBe("");
- });
-
- it("should handle content with only control characters", () => {
- const result = sanitizeContent("\x00\x01\x02\x03");
- expect(result).toBe("");
- });
-
- it("should handle content with multiple consecutive spaces", () => {
- const result = sanitizeContent("hello world");
- expect(result).toBe("hello world");
- });
-
- it("should handle Unicode characters", () => {
- const result = sanitizeContent("Hello 世界 🌍");
- expect(result).toBe("Hello 世界 🌍");
- });
-
- it("should handle URLs in query parameters", () => {
- const input = "https://github.com/redirect?url=https://github.com/target";
- const result = sanitizeContent(input);
-
- expect(result).toContain("github.com");
- expect(result).not.toContain("(redacted)");
- });
-
- it("should handle nested backticks", () => {
- const result = sanitizeContent("Already `@user` and @other");
- expect(result).toBe("Already `@user` and `@other`");
- });
- });
-
- describe("redacted domains collection", () => {
- let getRedactedDomains;
- let clearRedactedDomains;
- let writeRedactedDomainsLog;
- const fs = require("fs");
- const path = require("path");
-
- beforeEach(async () => {
- const module = await import("./sanitize_content.cjs");
- getRedactedDomains = module.getRedactedDomains;
- clearRedactedDomains = module.clearRedactedDomains;
- writeRedactedDomainsLog = module.writeRedactedDomainsLog;
- // Clear collected domains before each test
- clearRedactedDomains();
- });
-
- it("should collect redacted HTTPS domains", () => {
- sanitizeContent("Visit https://evil.com/malware");
- const domains = getRedactedDomains();
- expect(domains.length).toBe(1);
- expect(domains[0]).toBe("evil.com");
- });
-
- it("should collect redacted HTTP domains", () => {
- sanitizeContent("Visit http://example.com");
- const domains = getRedactedDomains();
- expect(domains.length).toBe(1);
- expect(domains[0]).toBe("example.com");
- });
-
- it("should collect redacted dangerous protocols", () => {
- sanitizeContent("Click javascript:alert(1)");
- const domains = getRedactedDomains();
- expect(domains.length).toBe(1);
- expect(domains[0]).toBe("javascript:");
- });
-
- it("should collect multiple redacted domains", () => {
- sanitizeContent("Visit https://bad1.com and http://bad2.com");
- const domains = getRedactedDomains();
- expect(domains.length).toBe(2);
- expect(domains).toContain("bad1.com");
- expect(domains).toContain("bad2.com");
- });
-
- it("should not collect allowed domains", () => {
- sanitizeContent("Visit https://github.com/repo");
- const domains = getRedactedDomains();
- expect(domains.length).toBe(0);
- });
-
- it("should clear collected domains", () => {
- sanitizeContent("Visit https://evil.com");
- expect(getRedactedDomains().length).toBe(1);
- clearRedactedDomains();
- expect(getRedactedDomains().length).toBe(0);
- });
-
- it("should return a copy of domains array", () => {
- sanitizeContent("Visit https://evil.com");
- const domains1 = getRedactedDomains();
- const domains2 = getRedactedDomains();
- expect(domains1).not.toBe(domains2);
- expect(domains1).toEqual(domains2);
- });
-
- describe("writeRedactedDomainsLog", () => {
- const testDir = "/tmp/gh-aw-test-redacted";
- const testFile = `${testDir}/redacted-urls.log`;
-
- afterEach(() => {
- // Clean up test files
- if (fs.existsSync(testFile)) {
- fs.unlinkSync(testFile);
- }
- if (fs.existsSync(testDir)) {
- fs.rmSync(testDir, { recursive: true, force: true });
- }
- });
-
- it("should return null when no domains collected", () => {
- const result = writeRedactedDomainsLog(testFile);
- expect(result).toBeNull();
- expect(fs.existsSync(testFile)).toBe(false);
- });
-
- it("should write domains to log file", () => {
- sanitizeContent("Visit https://evil.com/malware");
- const result = writeRedactedDomainsLog(testFile);
- expect(result).toBe(testFile);
- expect(fs.existsSync(testFile)).toBe(true);
-
- const content = fs.readFileSync(testFile, "utf8");
- expect(content).toContain("evil.com");
- // Should NOT contain the full URL, only the domain
- expect(content).not.toContain("https://evil.com/malware");
- });
-
- it("should write multiple domains to log file", () => {
- sanitizeContent("Visit https://bad1.com and http://bad2.com");
- writeRedactedDomainsLog(testFile);
-
- const content = fs.readFileSync(testFile, "utf8");
- const lines = content.trim().split("\n");
- expect(lines.length).toBe(2);
- expect(content).toContain("bad1.com");
- expect(content).toContain("bad2.com");
- });
-
- it("should create directory if it does not exist", () => {
- const nestedFile = `${testDir}/nested/redacted-urls.log`;
- sanitizeContent("Visit https://evil.com");
- writeRedactedDomainsLog(nestedFile);
- expect(fs.existsSync(nestedFile)).toBe(true);
-
- // Clean up nested directory
- fs.unlinkSync(nestedFile);
- fs.rmdirSync(path.dirname(nestedFile));
- });
-
- it("should use default path when not specified", () => {
- const defaultPath = "/tmp/gh-aw/redacted-urls.log";
- sanitizeContent("Visit https://evil.com");
- const result = writeRedactedDomainsLog();
- expect(result).toBe(defaultPath);
- expect(fs.existsSync(defaultPath)).toBe(true);
-
- // Clean up
- fs.unlinkSync(defaultPath);
- });
- });
- });
-});
diff --git a/pkg/workflow/js/sanitize_label_content.test.cjs b/pkg/workflow/js/sanitize_label_content.test.cjs
deleted file mode 100644
index b07e343d1d..0000000000
--- a/pkg/workflow/js/sanitize_label_content.test.cjs
+++ /dev/null
@@ -1,90 +0,0 @@
-import { describe, it, expect } from "vitest";
-const { sanitizeLabelContent } = require("./sanitize_label_content.cjs");
-describe("sanitize_label_content.cjs", () => {
- describe("sanitizeLabelContent", () => {
- (it("should return empty string for null input", () => {
- expect(sanitizeLabelContent(null)).toBe("");
- }),
- it("should return empty string for undefined input", () => {
- expect(sanitizeLabelContent(void 0)).toBe("");
- }),
- it("should return empty string for non-string input", () => {
- (expect(sanitizeLabelContent(123)).toBe(""), expect(sanitizeLabelContent({})).toBe(""), expect(sanitizeLabelContent([])).toBe(""));
- }),
- it("should trim whitespace from input", () => {
- (expect(sanitizeLabelContent(" test ")).toBe("test"), expect(sanitizeLabelContent("\n\ttest\n\t")).toBe("test"));
- }),
- it("should remove control characters", () => {
- expect(sanitizeLabelContent("test\0\blabel")).toBe("testlabel");
- }),
- it("should remove DEL character (0x7F)", () => {
- expect(sanitizeLabelContent("testlabel")).toBe("testlabel");
- }),
- it("should preserve newline character", () => {
- expect(sanitizeLabelContent("test\nlabel")).toBe("test\nlabel");
- }),
- it("should remove ANSI escape codes", () => {
- expect(sanitizeLabelContent("[31mred text[0m")).toBe("red text");
- }),
- it("should remove various ANSI codes", () => {
- expect(sanitizeLabelContent("[1;32mBold Green[0m[4mUnderline[0m")).toBe("Bold GreenUnderline");
- }),
- it("should neutralize @mentions by wrapping in backticks", () => {
- (expect(sanitizeLabelContent("Hello @user")).toBe("Hello `@user`"), expect(sanitizeLabelContent("@user said something")).toBe("`@user` said something"));
- }),
- it("should neutralize @org/team mentions", () => {
- expect(sanitizeLabelContent("Hello @myorg/myteam")).toBe("Hello `@myorg/myteam`");
- }),
- it("should not neutralize @mentions already in backticks", () => {
- expect(sanitizeLabelContent("Already `@user` handled")).toBe("Already `@user` handled");
- }),
- it("should neutralize multiple @mentions", () => {
- expect(sanitizeLabelContent("@user1 and @user2 are here")).toBe("`@user1` and `@user2` are here");
- }),
- it("should remove HTML special characters", () => {
- expect(sanitizeLabelContent("test<>&'\"label")).toBe("testlabel");
- }),
- it("should remove less-than signs", () => {
- expect(sanitizeLabelContent("a < b")).toBe("a b");
- }),
- it("should remove greater-than signs", () => {
- expect(sanitizeLabelContent("a > b")).toBe("a b");
- }),
- it("should remove ampersands", () => {
- expect(sanitizeLabelContent("test & label")).toBe("test label");
- }),
- it("should remove single and double quotes", () => {
- expect(sanitizeLabelContent('test\'s "label"')).toBe("tests label");
- }),
- it("should handle complex input with multiple sanitizations", () => {
- expect(sanitizeLabelContent(" @user [31mred[0m test&label ")).toBe("`@user` red tag testlabel");
- }),
- it("should handle empty string input", () => {
- expect(sanitizeLabelContent("")).toBe("");
- }),
- it("should handle whitespace-only input", () => {
- expect(sanitizeLabelContent(" \n\t ")).toBe("");
- }),
- it("should preserve normal alphanumeric characters", () => {
- (expect(sanitizeLabelContent("bug123")).toBe("bug123"), expect(sanitizeLabelContent("feature-request")).toBe("feature-request"));
- }),
- it("should preserve hyphens and underscores", () => {
- expect(sanitizeLabelContent("test-label_123")).toBe("test-label_123");
- }),
- it("should handle consecutive control characters", () => {
- expect(sanitizeLabelContent("test\0label")).toBe("testlabel");
- }),
- it("should handle @mentions at various positions", () => {
- (expect(sanitizeLabelContent("start @user end")).toBe("start `@user` end"), expect(sanitizeLabelContent("@user at start")).toBe("`@user` at start"), expect(sanitizeLabelContent("at end @user")).toBe("at end `@user`"));
- }),
- it("should not treat email-like patterns as @mentions after alphanumerics", () => {
- expect(sanitizeLabelContent("email@example.com")).toBe("email@example.com");
- }),
- it("should handle username edge cases", () => {
- (expect(sanitizeLabelContent("@a")).toBe("`@a`"), expect(sanitizeLabelContent("@user-name-123")).toBe("`@user-name-123`"));
- }),
- it("should combine all sanitization rules correctly", () => {
- expect(sanitizeLabelContent(' [31m@user[0m says & "goodbye" ')).toBe("`@user` says hello goodbye");
- }));
- });
-});
diff --git a/pkg/workflow/js/sanitize_output.test.cjs b/pkg/workflow/js/sanitize_output.test.cjs
deleted file mode 100644
index 348f2b373a..0000000000
--- a/pkg/workflow/js/sanitize_output.test.cjs
+++ /dev/null
@@ -1,565 +0,0 @@
-import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
-import fs from "fs";
-import path from "path";
-const mockCore = {
- debug: vi.fn(),
- info: vi.fn(),
- notice: vi.fn(),
- warning: vi.fn(),
- error: vi.fn(),
- setFailed: vi.fn(),
- setOutput: vi.fn(),
- exportVariable: vi.fn(),
- setSecret: vi.fn(),
- getInput: vi.fn(),
- getBooleanInput: vi.fn(),
- getMultilineInput: vi.fn(),
- getState: vi.fn(),
- saveState: vi.fn(),
- startGroup: vi.fn(),
- endGroup: vi.fn(),
- group: vi.fn(),
- addPath: vi.fn(),
- setCommandEcho: vi.fn(),
- isDebug: vi.fn().mockReturnValue(!1),
- getIDToken: vi.fn(),
- toPlatformPath: vi.fn(),
- toPosixPath: vi.fn(),
- toWin32Path: vi.fn(),
- summary: { addRaw: vi.fn().mockReturnThis(), write: vi.fn().mockResolvedValue() },
-};
-((global.core = mockCore),
- describe("sanitize_output.cjs", () => {
- let sanitizeScript, sanitizeContentFunction;
- (beforeEach(() => {
- (vi.clearAllMocks(), delete process.env.GH_AW_SAFE_OUTPUTS, delete process.env.GH_AW_ALLOWED_DOMAINS);
- const scriptPath = path.join(process.cwd(), "sanitize_output.cjs");
- sanitizeScript = fs.readFileSync(scriptPath, "utf8");
- const scriptWithExport = sanitizeScript.replace("module.exports = { main };", "global.testSanitizeContent = sanitizeContent;");
- (eval(scriptWithExport), (sanitizeContentFunction = global.testSanitizeContent));
- }),
- describe("sanitizeContent function", () => {
- (it("should handle null and undefined inputs", () => {
- (expect(sanitizeContentFunction(null)).toBe(""), expect(sanitizeContentFunction(void 0)).toBe(""), expect(sanitizeContentFunction("")).toBe(""));
- }),
- it("should neutralize @mentions by wrapping in backticks", () => {
- const result = sanitizeContentFunction("Hello @user and @org/team");
- (expect(result).toContain("`@user`"), expect(result).toContain("`@org/team`"));
- }),
- it("should not neutralize @mentions inside code blocks", () => {
- const result = sanitizeContentFunction("Check `@user` in code and @realuser outside");
- (expect(result).toContain("`@user`"), expect(result).toContain("`@realuser`"));
- }),
- it("should neutralize bot trigger phrases", () => {
- const result = sanitizeContentFunction("This fixes #123 and closes #456. Also resolves #789");
- (expect(result).toContain("`fixes #123`"), expect(result).toContain("`closes #456`"), expect(result).toContain("`resolves #789`"));
- }),
- it("should remove control characters except newlines and tabs", () => {
- const result = sanitizeContentFunction("Hello\0world\f\nNext line\tbad");
- (expect(result).not.toContain("\0"), expect(result).not.toContain("\f"), expect(result).not.toContain(""), expect(result).toContain("\n"), expect(result).toContain("\t"));
- }),
- it("should convert XML tags to parentheses format", () => {
- const result = sanitizeContentFunction('