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 = "
  1. First
  2. Second
"; + const result = sanitizeContent(input); + expect(result).toBe(input); + }); + + it("should preserve blockquote tags", () => { + const input = "
This is a quote
"; + const result = sanitizeContent(input); + expect(result).toBe(input); + }); + + it("should handle mixed allowed tags with formatting", () => { + const input = "

This is bold and italic text.
New line here.

"; + const result = sanitizeContent(input); + expect(result).toBe(input); + }); + + it("should handle nested list structure", () => { + const input = ""; + const result = sanitizeContent(input); + expect(result).toBe(input); + }); + + it("should preserve details and summary tags", () => { + const result1 = sanitizeContent("
content
"); + expect(result1).toBe("
content
"); + + const result2 = sanitizeContent("content"); + expect(result2).toBe("content"); + }); + + it("should convert removed tags that are no longer allowed", () => { + // Tag that was previously allowed but is now removed: u + const result3 = sanitizeContent("content"); + expect(result3).toBe("(u)content(/u)"); + }); + + it("should preserve heading tags h1-h6", () => { + const headings = ["h1", "h2", "h3", "h4", "h5", "h6"]; + headings.forEach(tag => { + const input = `<${tag}>Heading`; + const result = sanitizeContent(input); + expect(result).toBe(input); + }); + }); + + it("should preserve hr tag", () => { + const result = sanitizeContent("Content before
Content after"); + expect(result).toBe("Content before
Content after"); + }); + + it("should preserve pre tag", () => { + const input = "
Code block content
"; + const result = sanitizeContent(input); + expect(result).toBe(input); + }); + + it("should preserve sub and sup tags", () => { + const input1 = "H2O"; + const result1 = sanitizeContent(input1); + expect(result1).toBe(input1); + + const input2 = "E=mc2"; + const result2 = sanitizeContent(input2); + expect(result2).toBe(input2); + }); + + it("should preserve table structure tags", () => { + const input = "
Header
Data
"; + const result = sanitizeContent(input); + expect(result).toBe(input); + }); + }); + + describe("ANSI escape sequence removal", () => { + it("should remove ANSI color codes", () => { + const result = sanitizeContent("\x1b[31mred text\x1b[0m"); + expect(result).toBe("red text"); + }); + + it("should remove various ANSI codes", () => { + const result = sanitizeContent("\x1b[1;32mBold Green\x1b[0m"); + expect(result).toBe("Bold Green"); + }); + }); + + describe("control character removal", () => { + it("should remove control characters", () => { + const result = sanitizeContent("test\x00\x01\x02\x03content"); + expect(result).toBe("testcontent"); + }); + + it("should preserve newlines and tabs", () => { + const result = sanitizeContent("test\ncontent\twith\ttabs"); + expect(result).toBe("test\ncontent\twith\ttabs"); + }); + + it("should remove DEL character", () => { + const result = sanitizeContent("test\x7Fcontent"); + expect(result).toBe("testcontent"); + }); + }); + + describe("URL protocol sanitization", () => { + it("should allow HTTPS URLs", () => { + const result = sanitizeContent("Visit https://github.com"); + expect(result).toBe("Visit https://github.com"); + }); + + it("should redact HTTP URLs", () => { + const result = sanitizeContent("Visit http://example.com"); + expect(result).toContain("(redacted)"); + expect(mockCore.info).toHaveBeenCalled(); + }); + + it("should redact javascript: URLs", () => { + const result = sanitizeContent("Click javascript:alert('xss')"); + expect(result).toContain("(redacted)"); + }); + + it("should redact data: URLs", () => { + const result = sanitizeContent("Image data:image/png;base64,abc123"); + expect(result).toContain("(redacted)"); + }); + + it("should preserve file paths with colons", () => { + const result = sanitizeContent("C:\\path\\to\\file"); + expect(result).toBe("C:\\path\\to\\file"); + }); + + it("should preserve namespace patterns", () => { + const result = sanitizeContent("std::vector::push_back"); + expect(result).toBe("std::vector::push_back"); + }); + }); + + describe("URL domain filtering", () => { + it("should allow default GitHub domains", () => { + const urls = ["https://github.com/repo", "https://api.github.com/endpoint", "https://raw.githubusercontent.com/file", "https://example.github.io/page"]; + + urls.forEach(url => { + const result = sanitizeContent(`Visit ${url}`); + expect(result).toBe(`Visit ${url}`); + }); + }); + + it("should redact disallowed domains", () => { + const result = sanitizeContent("Visit https://evil.com/malicious"); + expect(result).toContain("(redacted)"); + expect(mockCore.info).toHaveBeenCalled(); + }); + + it("should use custom allowed domains from environment", () => { + process.env.GH_AW_ALLOWED_DOMAINS = "example.com,trusted.net"; + const result = sanitizeContent("Visit https://example.com/page"); + expect(result).toBe("Visit https://example.com/page"); + }); + + it("should extract and allow GitHub Enterprise domains", () => { + process.env.GITHUB_SERVER_URL = "https://github.company.com"; + const result = sanitizeContent("Visit https://github.company.com/repo"); + expect(result).toBe("Visit https://github.company.com/repo"); + }); + + it("should allow subdomains of allowed domains", () => { + const result = sanitizeContent("Visit https://subdomain.github.com/page"); + expect(result).toBe("Visit https://subdomain.github.com/page"); + }); + + it("should log redacted domains", () => { + sanitizeContent("Visit https://verylongdomainnamefortest.com/page"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Redacted URL:")); + expect(mockCore.debug).toHaveBeenCalledWith(expect.stringContaining("Redacted URL (full):")); + }); + }); + + describe("bot trigger neutralization", () => { + it("should neutralize 'fixes #123' patterns", () => { + const result = sanitizeContent("This fixes #123"); + expect(result).toBe("This `fixes #123`"); + }); + + it("should neutralize 'closes #456' patterns", () => { + const result = sanitizeContent("PR closes #456"); + expect(result).toBe("PR `closes #456`"); + }); + + it("should neutralize 'resolves #789' patterns", () => { + const result = sanitizeContent("This resolves #789"); + expect(result).toBe("This `resolves #789`"); + }); + + it("should handle various bot trigger verbs", () => { + const triggers = ["fix", "fixes", "close", "closes", "resolve", "resolves"]; + triggers.forEach(verb => { + const result = sanitizeContent(`This ${verb} #123`); + expect(result).toBe(`This \`${verb} #123\``); + }); + }); + + it("should neutralize alphanumeric issue references", () => { + const result = sanitizeContent("fixes #abc123def"); + expect(result).toBe("`fixes #abc123def`"); + }); + }); + + describe("content truncation", () => { + it("should truncate content exceeding max length", () => { + const longContent = "x".repeat(600000); + const result = sanitizeContent(longContent); + + expect(result.length).toBeLessThan(longContent.length); + expect(result).toContain("[Content truncated due to length]"); + }); + + it("should truncate content exceeding max lines", () => { + const manyLines = Array(70000).fill("line").join("\n"); + const result = sanitizeContent(manyLines); + + expect(result.split("\n").length).toBeLessThan(70000); + expect(result).toContain("[Content truncated due to line count]"); + }); + + it("should respect custom max length parameter", () => { + const content = "x".repeat(200); + const result = sanitizeContent(content, 100); + + expect(result.length).toBeLessThanOrEqual(100 + 50); // +50 for truncation message + expect(result).toContain("[Content truncated"); + }); + + it("should not truncate short content", () => { + const shortContent = "This is a short message"; + const result = sanitizeContent(shortContent); + + expect(result).toBe(shortContent); + expect(result).not.toContain("[Content truncated"); + }); + }); + + describe("combined sanitization", () => { + it("should apply all sanitizations correctly", () => { + const input = ` + + Hello @user, visit https://github.com + + This fixes #123 + \x1b[31mRed text\x1b[0m + `; + + const result = sanitizeContent(input); + + expect(result).not.toContain(""); + expect(result).toContain("`@user`"); + expect(result).toContain("https://github.com"); + expect(result).not.toContain(""]; + + maliciousInputs.forEach(input => { + const result = sanitizeContent(input); + expect(result).not.toContain(" { + const input = "
Header
Data
"; + const result = sanitizeContent(input); + + expect(result).toBe(input); + }); + }); + + describe("edge cases", () => { + it("should handle empty string", () => { + expect(sanitizeContent("")).toBe(""); + }); + + it("should handle whitespace-only input", () => { + expect(sanitizeContent(" \n\t ")).toBe(""); + }); + + it("should handle content with only control characters", () => { + const result = sanitizeContent("\x00\x01\x02\x03"); + expect(result).toBe(""); + }); + + it("should handle content with multiple consecutive spaces", () => { + const result = sanitizeContent("hello world"); + expect(result).toBe("hello world"); + }); + + it("should handle Unicode characters", () => { + const result = sanitizeContent("Hello 世界 🌍"); + expect(result).toBe("Hello 世界 🌍"); + }); + + it("should handle URLs in query parameters", () => { + const input = "https://github.com/redirect?url=https://github.com/target"; + const result = sanitizeContent(input); + + expect(result).toContain("github.com"); + expect(result).not.toContain("(redacted)"); + }); + + it("should handle nested backticks", () => { + const result = sanitizeContent("Already `@user` and @other"); + expect(result).toBe("Already `@user` and `@other`"); + }); + }); + + describe("redacted domains collection", () => { + let getRedactedDomains; + let clearRedactedDomains; + let writeRedactedDomainsLog; + const fs = require("fs"); + const path = require("path"); + + beforeEach(async () => { + const module = await import("./sanitize_content.cjs"); + getRedactedDomains = module.getRedactedDomains; + clearRedactedDomains = module.clearRedactedDomains; + writeRedactedDomainsLog = module.writeRedactedDomainsLog; + // Clear collected domains before each test + clearRedactedDomains(); + }); + + it("should collect redacted HTTPS domains", () => { + sanitizeContent("Visit https://evil.com/malware"); + const domains = getRedactedDomains(); + expect(domains.length).toBe(1); + expect(domains[0]).toBe("evil.com"); + }); + + it("should collect redacted HTTP domains", () => { + sanitizeContent("Visit http://example.com"); + const domains = getRedactedDomains(); + expect(domains.length).toBe(1); + expect(domains[0]).toBe("example.com"); + }); + + it("should collect redacted dangerous protocols", () => { + sanitizeContent("Click javascript:alert(1)"); + const domains = getRedactedDomains(); + expect(domains.length).toBe(1); + expect(domains[0]).toBe("javascript:"); + }); + + it("should collect multiple redacted domains", () => { + sanitizeContent("Visit https://bad1.com and http://bad2.com"); + const domains = getRedactedDomains(); + expect(domains.length).toBe(2); + expect(domains).toContain("bad1.com"); + expect(domains).toContain("bad2.com"); + }); + + it("should not collect allowed domains", () => { + sanitizeContent("Visit https://github.com/repo"); + const domains = getRedactedDomains(); + expect(domains.length).toBe(0); + }); + + it("should clear collected domains", () => { + sanitizeContent("Visit https://evil.com"); + expect(getRedactedDomains().length).toBe(1); + clearRedactedDomains(); + expect(getRedactedDomains().length).toBe(0); + }); + + it("should return a copy of domains array", () => { + sanitizeContent("Visit https://evil.com"); + const domains1 = getRedactedDomains(); + const domains2 = getRedactedDomains(); + expect(domains1).not.toBe(domains2); + expect(domains1).toEqual(domains2); + }); + + describe("writeRedactedDomainsLog", () => { + const testDir = "/tmp/gh-aw-test-redacted"; + const testFile = `${testDir}/redacted-urls.log`; + + afterEach(() => { + // Clean up test files + if (fs.existsSync(testFile)) { + fs.unlinkSync(testFile); + } + if (fs.existsSync(testDir)) { + fs.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("red text")).toBe("red text"); + }), + it("should remove various ANSI codes", () => { + expect(sanitizeLabelContent("Bold GreenUnderline")).toBe("Bold GreenUnderline"); + }), + it("should neutralize @mentions by wrapping in backticks", () => { + (expect(sanitizeLabelContent("Hello @user")).toBe("Hello `@user`"), expect(sanitizeLabelContent("@user said something")).toBe("`@user` said something")); + }), + it("should neutralize @org/team mentions", () => { + expect(sanitizeLabelContent("Hello @myorg/myteam")).toBe("Hello `@myorg/myteam`"); + }), + it("should not neutralize @mentions already in backticks", () => { + expect(sanitizeLabelContent("Already `@user` handled")).toBe("Already `@user` handled"); + }), + it("should neutralize multiple @mentions", () => { + expect(sanitizeLabelContent("@user1 and @user2 are here")).toBe("`@user1` and `@user2` are here"); + }), + it("should remove HTML special characters", () => { + expect(sanitizeLabelContent("test<>&'\"label")).toBe("testlabel"); + }), + it("should remove less-than signs", () => { + expect(sanitizeLabelContent("a < b")).toBe("a b"); + }), + it("should remove greater-than signs", () => { + expect(sanitizeLabelContent("a > b")).toBe("a b"); + }), + it("should remove ampersands", () => { + expect(sanitizeLabelContent("test & label")).toBe("test label"); + }), + it("should remove single and double quotes", () => { + expect(sanitizeLabelContent('test\'s "label"')).toBe("tests label"); + }), + it("should handle complex input with multiple sanitizations", () => { + expect(sanitizeLabelContent(" @user red test&label ")).toBe("`@user` red tag testlabel"); + }), + it("should handle empty string input", () => { + expect(sanitizeLabelContent("")).toBe(""); + }), + it("should handle whitespace-only input", () => { + expect(sanitizeLabelContent(" \n\t ")).toBe(""); + }), + it("should preserve normal alphanumeric characters", () => { + (expect(sanitizeLabelContent("bug123")).toBe("bug123"), expect(sanitizeLabelContent("feature-request")).toBe("feature-request")); + }), + it("should preserve hyphens and underscores", () => { + expect(sanitizeLabelContent("test-label_123")).toBe("test-label_123"); + }), + it("should handle consecutive control characters", () => { + expect(sanitizeLabelContent("test\0label")).toBe("testlabel"); + }), + it("should handle @mentions at various positions", () => { + (expect(sanitizeLabelContent("start @user end")).toBe("start `@user` end"), expect(sanitizeLabelContent("@user at start")).toBe("`@user` at start"), expect(sanitizeLabelContent("at end @user")).toBe("at end `@user`")); + }), + it("should not treat email-like patterns as @mentions after alphanumerics", () => { + expect(sanitizeLabelContent("email@example.com")).toBe("email@example.com"); + }), + it("should handle username edge cases", () => { + (expect(sanitizeLabelContent("@a")).toBe("`@a`"), expect(sanitizeLabelContent("@user-name-123")).toBe("`@user-name-123`")); + }), + it("should combine all sanitization rules correctly", () => { + expect(sanitizeLabelContent(' @user says & "goodbye" ')).toBe("`@user` says hello goodbye"); + })); + }); +}); 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 = "
  • Item 1
  • Item 2
"; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - - it("should preserve ordered list tags", () => { - const input = "
  1. First
  2. Second
"; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - - it("should preserve blockquote tags", () => { - const input = "
This is a quote
"; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - - it("should handle mixed allowed tags with formatting", () => { - const input = "

This is bold and italic text.
New line here.

"; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - - it("should handle nested list structure", () => { - const input = "
  • Item 1
    • Nested item
  • Item 2
"; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - - it("should preserve details and summary tags", () => { - const result1 = sanitizeContent("
content
"); - expect(result1).toBe("
content
"); - - const result2 = sanitizeContent("content"); - expect(result2).toBe("content"); - }); - - it("should convert removed tags that are no longer allowed", () => { - // Tag that was previously allowed but is now removed: u - const result3 = sanitizeContent("content"); - expect(result3).toBe("(u)content(/u)"); - }); - - it("should preserve heading tags h1-h6", () => { - const headings = ["h1", "h2", "h3", "h4", "h5", "h6"]; - headings.forEach(tag => { - const input = `<${tag}>Heading`; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - }); - - it("should preserve hr tag", () => { - const result = sanitizeContent("Content before
Content after"); - expect(result).toBe("Content before
Content after"); - }); - - it("should preserve pre tag", () => { - const input = "
Code block content
"; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - - it("should preserve sub and sup tags", () => { - const input1 = "H2O"; - const result1 = sanitizeContent(input1); - expect(result1).toBe(input1); - - const input2 = "E=mc2"; - const result2 = sanitizeContent(input2); - expect(result2).toBe(input2); - }); - - it("should preserve table structure tags", () => { - const input = "
Header
Data
"; - const result = sanitizeContent(input); - expect(result).toBe(input); - }); - }); - - describe("ANSI escape sequence removal", () => { - it("should remove ANSI color codes", () => { - const result = sanitizeContent("\x1b[31mred text\x1b[0m"); - expect(result).toBe("red text"); - }); - - it("should remove various ANSI codes", () => { - const result = sanitizeContent("\x1b[1;32mBold Green\x1b[0m"); - expect(result).toBe("Bold Green"); - }); - }); - - describe("control character removal", () => { - it("should remove control characters", () => { - const result = sanitizeContent("test\x00\x01\x02\x03content"); - expect(result).toBe("testcontent"); - }); - - it("should preserve newlines and tabs", () => { - const result = sanitizeContent("test\ncontent\twith\ttabs"); - expect(result).toBe("test\ncontent\twith\ttabs"); - }); - - it("should remove DEL character", () => { - const result = sanitizeContent("test\x7Fcontent"); - expect(result).toBe("testcontent"); - }); - }); - - describe("URL protocol sanitization", () => { - it("should allow HTTPS URLs", () => { - const result = sanitizeContent("Visit https://github.com"); - expect(result).toBe("Visit https://github.com"); - }); - - it("should redact HTTP URLs", () => { - const result = sanitizeContent("Visit http://example.com"); - expect(result).toContain("(redacted)"); - expect(mockCore.info).toHaveBeenCalled(); - }); - - it("should redact javascript: URLs", () => { - const result = sanitizeContent("Click javascript:alert('xss')"); - expect(result).toContain("(redacted)"); - }); - - it("should redact data: URLs", () => { - const result = sanitizeContent("Image data:image/png;base64,abc123"); - expect(result).toContain("(redacted)"); - }); - - it("should preserve file paths with colons", () => { - const result = sanitizeContent("C:\\path\\to\\file"); - expect(result).toBe("C:\\path\\to\\file"); - }); - - it("should preserve namespace patterns", () => { - const result = sanitizeContent("std::vector::push_back"); - expect(result).toBe("std::vector::push_back"); - }); - }); - - describe("URL domain filtering", () => { - it("should allow default GitHub domains", () => { - const urls = ["https://github.com/repo", "https://api.github.com/endpoint", "https://raw.githubusercontent.com/file", "https://example.github.io/page"]; - - urls.forEach(url => { - const result = sanitizeContent(`Visit ${url}`); - expect(result).toBe(`Visit ${url}`); - }); - }); - - it("should redact disallowed domains", () => { - const result = sanitizeContent("Visit https://evil.com/malicious"); - expect(result).toContain("(redacted)"); - expect(mockCore.info).toHaveBeenCalled(); - }); - - it("should use custom allowed domains from environment", () => { - process.env.GH_AW_ALLOWED_DOMAINS = "example.com,trusted.net"; - const result = sanitizeContent("Visit https://example.com/page"); - expect(result).toBe("Visit https://example.com/page"); - }); - - it("should extract and allow GitHub Enterprise domains", () => { - process.env.GITHUB_SERVER_URL = "https://github.company.com"; - const result = sanitizeContent("Visit https://github.company.com/repo"); - expect(result).toBe("Visit https://github.company.com/repo"); - }); - - it("should allow subdomains of allowed domains", () => { - const result = sanitizeContent("Visit https://subdomain.github.com/page"); - expect(result).toBe("Visit https://subdomain.github.com/page"); - }); - - it("should log redacted domains", () => { - sanitizeContent("Visit https://verylongdomainnamefortest.com/page"); - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Redacted URL:")); - expect(mockCore.debug).toHaveBeenCalledWith(expect.stringContaining("Redacted URL (full):")); - }); - }); - - describe("bot trigger neutralization", () => { - it("should neutralize 'fixes #123' patterns", () => { - const result = sanitizeContent("This fixes #123"); - expect(result).toBe("This `fixes #123`"); - }); - - it("should neutralize 'closes #456' patterns", () => { - const result = sanitizeContent("PR closes #456"); - expect(result).toBe("PR `closes #456`"); - }); - - it("should neutralize 'resolves #789' patterns", () => { - const result = sanitizeContent("This resolves #789"); - expect(result).toBe("This `resolves #789`"); - }); - - it("should handle various bot trigger verbs", () => { - const triggers = ["fix", "fixes", "close", "closes", "resolve", "resolves"]; - triggers.forEach(verb => { - const result = sanitizeContent(`This ${verb} #123`); - expect(result).toBe(`This \`${verb} #123\``); - }); - }); - - it("should neutralize alphanumeric issue references", () => { - const result = sanitizeContent("fixes #abc123def"); - expect(result).toBe("`fixes #abc123def`"); - }); - }); - - describe("content truncation", () => { - it("should truncate content exceeding max length", () => { - const longContent = "x".repeat(600000); - const result = sanitizeContent(longContent); - - expect(result.length).toBeLessThan(longContent.length); - expect(result).toContain("[Content truncated due to length]"); - }); - - it("should truncate content exceeding max lines", () => { - const manyLines = Array(70000).fill("line").join("\n"); - const result = sanitizeContent(manyLines); - - expect(result.split("\n").length).toBeLessThan(70000); - expect(result).toContain("[Content truncated due to line count]"); - }); - - it("should respect custom max length parameter", () => { - const content = "x".repeat(200); - const result = sanitizeContent(content, 100); - - expect(result.length).toBeLessThanOrEqual(100 + 50); // +50 for truncation message - expect(result).toContain("[Content truncated"); - }); - - it("should not truncate short content", () => { - const shortContent = "This is a short message"; - const result = sanitizeContent(shortContent); - - expect(result).toBe(shortContent); - expect(result).not.toContain("[Content truncated"); - }); - }); - - describe("combined sanitization", () => { - it("should apply all sanitizations correctly", () => { - const input = ` - - Hello @user, visit https://github.com - - This fixes #123 - \x1b[31mRed text\x1b[0m - `; - - const result = sanitizeContent(input); - - expect(result).not.toContain(""); - expect(result).toContain("`@user`"); - expect(result).toContain("https://github.com"); - expect(result).not.toContain(""]; - - maliciousInputs.forEach(input => { - const result = sanitizeContent(input); - expect(result).not.toContain(" { - const input = "
Header
Data
"; - const result = sanitizeContent(input); - - expect(result).toBe(input); - }); - }); - - describe("edge cases", () => { - it("should handle empty string", () => { - expect(sanitizeContent("")).toBe(""); - }); - - it("should handle whitespace-only input", () => { - expect(sanitizeContent(" \n\t ")).toBe(""); - }); - - it("should handle content with only control characters", () => { - const result = sanitizeContent("\x00\x01\x02\x03"); - expect(result).toBe(""); - }); - - it("should handle content with multiple consecutive spaces", () => { - const result = sanitizeContent("hello world"); - expect(result).toBe("hello world"); - }); - - it("should handle Unicode characters", () => { - const result = sanitizeContent("Hello 世界 🌍"); - expect(result).toBe("Hello 世界 🌍"); - }); - - it("should handle URLs in query parameters", () => { - const input = "https://github.com/redirect?url=https://github.com/target"; - const result = sanitizeContent(input); - - expect(result).toContain("github.com"); - expect(result).not.toContain("(redacted)"); - }); - - it("should handle nested backticks", () => { - const result = sanitizeContent("Already `@user` and @other"); - expect(result).toBe("Already `@user` and `@other`"); - }); - }); - - describe("redacted domains collection", () => { - let getRedactedDomains; - let clearRedactedDomains; - let writeRedactedDomainsLog; - const fs = require("fs"); - const path = require("path"); - - beforeEach(async () => { - const module = await import("./sanitize_content.cjs"); - getRedactedDomains = module.getRedactedDomains; - clearRedactedDomains = module.clearRedactedDomains; - writeRedactedDomainsLog = module.writeRedactedDomainsLog; - // Clear collected domains before each test - clearRedactedDomains(); - }); - - it("should collect redacted HTTPS domains", () => { - sanitizeContent("Visit https://evil.com/malware"); - const domains = getRedactedDomains(); - expect(domains.length).toBe(1); - expect(domains[0]).toBe("evil.com"); - }); - - it("should collect redacted HTTP domains", () => { - sanitizeContent("Visit http://example.com"); - const domains = getRedactedDomains(); - expect(domains.length).toBe(1); - expect(domains[0]).toBe("example.com"); - }); - - it("should collect redacted dangerous protocols", () => { - sanitizeContent("Click javascript:alert(1)"); - const domains = getRedactedDomains(); - expect(domains.length).toBe(1); - expect(domains[0]).toBe("javascript:"); - }); - - it("should collect multiple redacted domains", () => { - sanitizeContent("Visit https://bad1.com and http://bad2.com"); - const domains = getRedactedDomains(); - expect(domains.length).toBe(2); - expect(domains).toContain("bad1.com"); - expect(domains).toContain("bad2.com"); - }); - - it("should not collect allowed domains", () => { - sanitizeContent("Visit https://github.com/repo"); - const domains = getRedactedDomains(); - expect(domains.length).toBe(0); - }); - - it("should clear collected domains", () => { - sanitizeContent("Visit https://evil.com"); - expect(getRedactedDomains().length).toBe(1); - clearRedactedDomains(); - expect(getRedactedDomains().length).toBe(0); - }); - - it("should return a copy of domains array", () => { - sanitizeContent("Visit https://evil.com"); - const domains1 = getRedactedDomains(); - const domains2 = getRedactedDomains(); - expect(domains1).not.toBe(domains2); - expect(domains1).toEqual(domains2); - }); - - describe("writeRedactedDomainsLog", () => { - const testDir = "/tmp/gh-aw-test-redacted"; - const testFile = `${testDir}/redacted-urls.log`; - - afterEach(() => { - // Clean up test files - if (fs.existsSync(testFile)) { - fs.unlinkSync(testFile); - } - if (fs.existsSync(testDir)) { - fs.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("red text")).toBe("red text"); - }), - it("should remove various ANSI codes", () => { - expect(sanitizeLabelContent("Bold GreenUnderline")).toBe("Bold GreenUnderline"); - }), - it("should neutralize @mentions by wrapping in backticks", () => { - (expect(sanitizeLabelContent("Hello @user")).toBe("Hello `@user`"), expect(sanitizeLabelContent("@user said something")).toBe("`@user` said something")); - }), - it("should neutralize @org/team mentions", () => { - expect(sanitizeLabelContent("Hello @myorg/myteam")).toBe("Hello `@myorg/myteam`"); - }), - it("should not neutralize @mentions already in backticks", () => { - expect(sanitizeLabelContent("Already `@user` handled")).toBe("Already `@user` handled"); - }), - it("should neutralize multiple @mentions", () => { - expect(sanitizeLabelContent("@user1 and @user2 are here")).toBe("`@user1` and `@user2` are here"); - }), - it("should remove HTML special characters", () => { - expect(sanitizeLabelContent("test<>&'\"label")).toBe("testlabel"); - }), - it("should remove less-than signs", () => { - expect(sanitizeLabelContent("a < b")).toBe("a b"); - }), - it("should remove greater-than signs", () => { - expect(sanitizeLabelContent("a > b")).toBe("a b"); - }), - it("should remove ampersands", () => { - expect(sanitizeLabelContent("test & label")).toBe("test label"); - }), - it("should remove single and double quotes", () => { - expect(sanitizeLabelContent('test\'s "label"')).toBe("tests label"); - }), - it("should handle complex input with multiple sanitizations", () => { - expect(sanitizeLabelContent(" @user red test&label ")).toBe("`@user` red tag testlabel"); - }), - it("should handle empty string input", () => { - expect(sanitizeLabelContent("")).toBe(""); - }), - it("should handle whitespace-only input", () => { - expect(sanitizeLabelContent(" \n\t ")).toBe(""); - }), - it("should preserve normal alphanumeric characters", () => { - (expect(sanitizeLabelContent("bug123")).toBe("bug123"), expect(sanitizeLabelContent("feature-request")).toBe("feature-request")); - }), - it("should preserve hyphens and underscores", () => { - expect(sanitizeLabelContent("test-label_123")).toBe("test-label_123"); - }), - it("should handle consecutive control characters", () => { - expect(sanitizeLabelContent("test\0label")).toBe("testlabel"); - }), - it("should handle @mentions at various positions", () => { - (expect(sanitizeLabelContent("start @user end")).toBe("start `@user` end"), expect(sanitizeLabelContent("@user at start")).toBe("`@user` at start"), expect(sanitizeLabelContent("at end @user")).toBe("at end `@user`")); - }), - it("should not treat email-like patterns as @mentions after alphanumerics", () => { - expect(sanitizeLabelContent("email@example.com")).toBe("email@example.com"); - }), - it("should handle username edge cases", () => { - (expect(sanitizeLabelContent("@a")).toBe("`@a`"), expect(sanitizeLabelContent("@user-name-123")).toBe("`@user-name-123`")); - }), - it("should combine all sanitization rules correctly", () => { - expect(sanitizeLabelContent(' @user says & "goodbye" ')).toBe("`@user` says hello goodbye"); - })); - }); -}); 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('